feat(brain): Gedächtnis nach scope trennen (system/personal)

Neues Feld scope=system|personal auf jedem Memory-Punkt. Bootstrap-Export
getrennt: System-Regeln (generisch, teilbar) vs. Persönliches (Name,
Zugangsdaten, Projekte). Import ist scope-sicher — ein System-Import löscht
NICHT die persönlichen pinned Memories. seed_rules + AGENT.md/TOOLING.md →
system, USER.md-Präferenzen → personal. Backfill für Bestand (57 system /
617 personal). Diagnostic: zwei Export-Buttons, scope-Badge (SYS/PRIV) +
Umschalter pro Memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:19:06 +02:00
co-authored by Claude Opus 4.8
parent 07ccf05429
commit 5992a7e441
6 changed files with 229 additions and 29 deletions
+90
View File
@@ -0,0 +1,90 @@
"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope`
(system | personal) zu. Sicher & reversibel — Stefan kann pro Eintrag in der
Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden.
Heuristik (datengetrieben aus dem realen Bestand):
- type=preference / fact / conversation / reminder -> personal
- source in (seed, auto-feedback) -> system
- type=identity -> system
- type in (rule, tool, skill) und category in SYSTEM_CATS -> system
- sonst -> personal (sicher: nichts leakt)
Aufruf im Brain-Container:
docker exec aria-brain python3 /app/backfill_scope.py # dry-run
docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt
"""
import os
import sys
from collections import Counter
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
COLLECTION = "aria_memory"
SYSTEM_CATS = {
"sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten",
"voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit",
"pentest", "ausgabe",
}
def compute_scope(pl: dict) -> str:
typ = pl.get("type")
src = pl.get("source")
cat = (pl.get("category") or "").lower()
if typ == "preference":
return "personal"
if typ in ("fact", "conversation", "reminder"):
return "personal"
if src in ("seed", "auto-feedback"):
return "system"
if typ == "identity":
return "system"
if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS:
return "system"
return "personal"
def main():
apply = "--apply" in sys.argv
force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben
c = QdrantClient(
host=os.environ.get("QDRANT_HOST", "aria-qdrant"),
port=int(os.environ.get("QDRANT_PORT", "6333")),
)
pts, _ = c.scroll(collection_name=COLLECTION, limit=5000,
with_payload=True, with_vectors=False)
per_scope: dict[str, list] = {"system": [], "personal": []}
pinned_examples = Counter()
skipped = 0
for p in pts:
pl = p.payload or {}
if pl.get("scope") in ("system", "personal") and not force:
skipped += 1
continue
scope = compute_scope(pl)
per_scope[scope].append(p.id)
if pl.get("pinned"):
pinned_examples[(scope, pl.get("source"), pl.get("type"),
pl.get("category"))] += 1
print(f"total={len(pts)} skipped(already set)={skipped}")
print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
print("pinned split (scope, source, type, category):")
for k, v in sorted(pinned_examples.items()):
print(" ", k, v)
if not apply:
print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
return
for scope, ids in per_scope.items():
if not ids:
continue
c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids)
print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
if __name__ == "__main__":
main()
+45 -13
View File
@@ -190,6 +190,7 @@ class MemoryIn(BaseModel):
pinned: bool = False pinned: bool = False
category: str = "" category: str = ""
source: str = "manual" source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge # Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
content: Optional[str] = None content: Optional[str] = None
pinned: Optional[bool] = None pinned: Optional[bool] = None
category: Optional[str] = None category: Optional[str] = None
scope: Optional[str] = None # system | personal
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
pinned: bool pinned: bool
category: str category: str
source: str source: str
scope: str = "personal"
tags: List[str] tags: List[str]
created_at: str created_at: str
updated_at: str updated_at: str
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
pinned=body.pinned, pinned=body.pinned,
category=body.category, category=body.category,
source=body.source, source=body.source,
scope=body.scope,
tags=body.tags, tags=body.tags,
conversation_id=body.conversation_id, conversation_id=body.conversation_id,
attachments=body.attachments or [], attachments=body.attachments or [],
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
existing.pinned = body.pinned existing.pinned = body.pinned
if body.category is not None: if body.category is not None:
existing.category = body.category existing.category = body.category
if body.scope is not None:
existing.scope = body.scope
if body.tags is not None: if body.tags is not None:
existing.tags = body.tags existing.tags = body.tags
@@ -537,12 +543,23 @@ def memory_import_files():
# Wiederherstellen einer schlanken ARIA nach Wipe. # Wiederherstellen einer schlanken ARIA nach Wipe.
@app.get("/memory/export-bootstrap") @app.get("/memory/export-bootstrap")
def memory_export_bootstrap(): def memory_export_bootstrap(scope: str = "system"):
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download.""" """Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
scope='system' → nur generische Regeln (fuer ein frisches System),
scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
scope='all' → alles pinned (Vollbackup).
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
s = store() s = store()
pinned = s.list_pinned() if scope == "all":
pinned = s.list_pinned()
elif scope in ("system", "personal"):
pinned = s.list_pinned_by_scope(scope)
else:
raise HTTPException(400, f"Ungueltiger scope: {scope}")
return { return {
"version": 1, "version": 2,
"scope": scope,
"exported_at": __import__("datetime").datetime.now( "exported_at": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc __import__("datetime").timezone.utc
).isoformat(), ).isoformat(),
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
"pinned": True, "pinned": True,
"category": p.category, "category": p.category,
"source": p.source, "source": p.source,
"scope": p.scope,
"tags": p.tags, "tags": p.tags,
} }
for p in pinned for p in pinned
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
class BootstrapBundle(BaseModel): class BootstrapBundle(BaseModel):
version: int = 1 version: int = 1
scope: Optional[str] = None # system | personal | all (aus dem Export)
memories: List[dict] memories: List[dict]
@app.post("/memory/import-bootstrap") @app.post("/memory/import-bootstrap")
def memory_import_bootstrap(body: BootstrapBundle): def memory_import_bootstrap(body: BootstrapBundle):
"""Loescht alle pinned Memories und importiert die im Bundle. """Importiert ein Bootstrap-Bundle scope-sicher.
Cold Memory (unpinned) bleibt unangetastet.
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
gehoert — ein System-Import laesst also die persoenlichen pinned Memories
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
(Vollbackup) werden alle pinned ersetzt.
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
Caller soll erst exportieren und dann importieren. Caller soll erst exportieren und dann importieren.
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
s = store() s = store()
e = embedder() e = embedder()
# Alle aktuell pinned Punkte loeschen
from qdrant_client.http import models as qm from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION from memory.vector_store import COLLECTION
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
bundle_scope = body.scope
if bundle_scope not in ("system", "personal", "all"):
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
# Nur die pinned Punkte des betroffenen scope loeschen.
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
if bundle_scope in ("system", "personal"):
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
s.client.delete( s.client.delete(
collection_name=COLLECTION, collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[ points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])),
) )
# Neue Punkte einspeisen # Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
created = 0 created = 0
for m in body.memories: for m in body.memories:
content = (m.get("content") or "").strip() content = (m.get("content") or "").strip()
if not content: if not content:
continue continue
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
point = MemoryPoint( point = MemoryPoint(
id="", id="",
type=m.get("type", "fact"), type=m.get("type", "fact"),
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
pinned=True, pinned=True,
category=m.get("category", ""), category=m.get("category", ""),
source=m.get("source", "bootstrap-import"), source=m.get("source", "bootstrap-import"),
scope=mscope,
tags=list(m.get("tags", [])), tags=list(m.get("tags", [])),
) )
vec = e.embed(content) vec = e.embed(content)
s.upsert(point, vec) s.upsert(point, vec)
created += 1 created += 1
return {"created": created, "deleted_previous_pinned": True} return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
# ─── Conversation-Loop ────────────────────────────────────────────── # ─── Conversation-Loop ──────────────────────────────────────────────
+52 -4
View File
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
content — eigentlicher Text (wird embedded) content — eigentlicher Text (wird embedded)
pinned — bool, True = Hot Memory (immer in Prompt) pinned — bool, True = Hot Memory (immer in Prompt)
source — import | conversation | manual source — import | conversation | manual
scope — system | personal. system = generische Regeln, die JEDER
braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit,
Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs-
daten, Projekte). Steuert den getrennten Bootstrap-Export.
tags — Liste von Strings tags — Liste von Strings
created_at, updated_at — ISO-Strings created_at, updated_at — ISO-Strings
conversation_id — optional, nur fuer type=conversation conversation_id — optional, nur fuer type=conversation
@@ -55,6 +59,7 @@ class MemoryPoint:
pinned: bool = False pinned: bool = False
category: str = "" category: str = ""
source: str = "manual" source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
created_at: str = "" created_at: str = ""
updated_at: str = "" updated_at: str = ""
@@ -74,6 +79,7 @@ class MemoryPoint:
"pinned": self.pinned, "pinned": self.pinned,
"category": self.category, "category": self.category,
"source": self.source, "source": self.source,
"scope": self.scope,
"tags": self.tags, "tags": self.tags,
"created_at": self.created_at, "created_at": self.created_at,
"updated_at": self.updated_at, "updated_at": self.updated_at,
@@ -94,6 +100,7 @@ class MemoryPoint:
pinned=payload.get("pinned", False), pinned=payload.get("pinned", False),
category=payload.get("category", ""), category=payload.get("category", ""),
source=payload.get("source", "manual"), source=payload.get("source", "manual"),
scope=payload.get("scope", "personal"),
tags=payload.get("tags", []), tags=payload.get("tags", []),
created_at=payload.get("created_at", ""), created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""), updated_at=payload.get("updated_at", ""),
@@ -120,14 +127,23 @@ class VectorStore:
collection_name=COLLECTION, collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE), vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
) )
# Indexe fuer typische Filter-Felder # Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
for field_name in ("type", "pinned", "category", "source", "migration_key"): # einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope).
self._ensure_indexes()
def _ensure_indexes(self):
for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"):
schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned"
else qm.PayloadSchemaType.KEYWORD)
try:
self.client.create_payload_index( self.client.create_payload_index(
collection_name=COLLECTION, collection_name=COLLECTION,
field_name=field_name, field_name=field_name,
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned" field_schema=schema,
else qm.PayloadSchemaType.BOOL,
) )
except Exception:
# Index existiert bereits — kein Problem.
pass
# ─── Schreib-Operationen ───────────────────────────────────────── # ─── Schreib-Operationen ─────────────────────────────────────────
@@ -164,6 +180,38 @@ class VectorStore:
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)) qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])) ]))
def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]:
"""Alle pinned Punkte eines scope (system | personal). Fuer den
getrennten Bootstrap-Export."""
return self._scroll(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)),
]))
def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]:
"""Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den
System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs-
daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via
memory_search — statt Stefan nach etwas zu fragen, das schon da ist.
Bewusst NUR die deliberat gespeicherten Punkte:
- nicht pinned (die sind eh schon voll im Prompt),
- kein type=conversation (Chat-Mitschnitte),
- kein source=distilled (die 100e auto-destillierten Gespraechs-
Fakten — die traegt das semantische Auto-Retrieval, sie hier
als Titel zu listen wuerde nur Kontext fressen).
So bleibt der Index klein (Dutzende statt Hunderte Zeilen)."""
return self._scroll(
filter=qm.Filter(
must_not=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")),
qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")),
]
),
limit=limit,
)
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]: def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
return self._scroll( return self._scroll(
filter=qm.Filter(must=[ filter=qm.Filter(must=[
+7
View File
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=f"User: {btitle}", type_="preference", title=f"User: {btitle}",
content=btext, category="allgemein", content=btext, category="allgemein",
migration_key=f"{source_file}/general-{idx}", migration_key=f"{source_file}/general-{idx}",
scope="personal",
)) ))
else: else:
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein" cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
@@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=title, type_="preference", title=title,
content=content, category=cat_key, content=content, category=cat_key,
migration_key=f"{source_file}/{cat_key}", migration_key=f"{source_file}/{cat_key}",
scope="personal",
)) ))
return points return points
@@ -283,7 +285,11 @@ def _mk(
migration_key: str, migration_key: str,
pinned: bool = True, pinned: bool = True,
category: str = "", category: str = "",
scope: str = "system",
) -> MemoryPoint: ) -> MemoryPoint:
# scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst
# (Identitaet, Sicherheit, Architektur) — das braucht jedes System.
# USER.md-Praeferenzen sind personal und uebergeben scope='personal'.
p = MemoryPoint( p = MemoryPoint(
id="", id="",
type=type_, type=type_,
@@ -292,6 +298,7 @@ def _mk(
pinned=pinned, pinned=pinned,
category=category, category=category,
source="import", source="import",
scope=scope,
tags=[], tags=[],
) )
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen # migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
+1
View File
@@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
"pinned": True, "pinned": True,
"category": rule.get("category", ""), "category": rule.get("category", ""),
"source": "seed", "source": "seed",
"scope": "system",
"tags": [], "tags": [],
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
+34 -12
View File
@@ -1080,11 +1080,13 @@
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;"> <div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
<div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div> <div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div>
<div style="color:#8888AA;font-size:11px;margin-bottom:8px;"> <div style="color:#8888AA;font-size:11px;margin-bottom:8px;">
Klein und schnell: <strong>nur</strong> die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON. Getrennt nach <strong>scope</strong>: <span style="color:#3FFF3F;">System</span> = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System.
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer. <span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet. Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
</div> </div>
<button class="btn secondary" onclick="exportBootstrap()" style="color:#FFD60A;border-color:#FFD60A;">Bootstrap exportieren (JSON)</button> <button class="btn secondary" onclick="exportBootstrap('system')" style="color:#3FFF3F;border-color:#3FFF3F;">System-Regeln exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('personal')" style="color:#FF9F0A;border-color:#FF9F0A;">⬇ Persönliches exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('all')" style="color:#FFD60A;border-color:#FFD60A;">⬇ Alles (Vollbackup)</button>
<input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)"> <input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)">
<button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button> <button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button>
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div> <div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
@@ -1408,6 +1410,11 @@
<input type="checkbox" id="memory-pinned"> <input type="checkbox" id="memory-pinned">
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span> <span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
</label> </label>
<label style="display:block;color:#8888AA;font-size:11px;margin-top:10px;margin-bottom:3px;">Scope (steuert Bootstrap-Export):</label>
<select id="memory-scope" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<option value="personal">🟠 Persönlich — Stefan-spezifisch, bleibt privat</option>
<option value="system">🟢 System — generische Regel, teilbar für frisches System</option>
</select>
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar --> <!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;"> <div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
@@ -5804,9 +5811,15 @@
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : ''; const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0; const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : ''; const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
// scope-Badge nur bei pinned (nur die werden exportiert — da zaehlt die Trennung).
const scopeBadge = m.pinned
? (m.scope === 'system'
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
: '';
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;"> return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')"> <div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge} <div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''} ${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div> </div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div> <div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
@@ -6016,6 +6029,7 @@
document.getElementById('memory-category').value = m.category || ''; document.getElementById('memory-category').value = m.category || '';
document.getElementById('memory-tags').value = (m.tags || []).join(', '); document.getElementById('memory-tags').value = (m.tags || []).join(', ');
document.getElementById('memory-pinned').checked = !!m.pinned; document.getElementById('memory-pinned').checked = !!m.pinned;
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
// Anhang-Block sichtbar — Liste rendern // Anhang-Block sichtbar — Liste rendern
if (attBlock) attBlock.style.display = 'block'; if (attBlock) attBlock.style.display = 'block';
if (attHint) attHint.style.display = 'none'; if (attHint) attHint.style.display = 'none';
@@ -6029,6 +6043,7 @@
document.getElementById('memory-category').value = ''; document.getElementById('memory-category').value = '';
document.getElementById('memory-tags').value = ''; document.getElementById('memory-tags').value = '';
document.getElementById('memory-pinned').checked = false; document.getElementById('memory-pinned').checked = false;
document.getElementById('memory-scope').value = 'personal';
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen // Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
if (attBlock) attBlock.style.display = 'none'; if (attBlock) attBlock.style.display = 'none';
if (attHint) attHint.style.display = 'block'; if (attHint) attHint.style.display = 'block';
@@ -6133,6 +6148,7 @@
const category = document.getElementById('memory-category').value.trim(); const category = document.getElementById('memory-category').value.trim();
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean); const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const pinned = document.getElementById('memory-pinned').checked; const pinned = document.getElementById('memory-pinned').checked;
const scope = document.getElementById('memory-scope').value || 'personal';
if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; } if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; } if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
@@ -6143,13 +6159,13 @@
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), { r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, pinned, category, tags }), body: JSON.stringify({ title, content, pinned, category, scope, tags }),
}); });
} else { } else {
r = await fetch('/api/brain/memory/save', { r = await fetch('/api/brain/memory/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, title, content, pinned, category, tags, source: 'manual' }), body: JSON.stringify({ type, title, content, pinned, category, scope, tags, source: 'manual' }),
}); });
} }
if (!r.ok) { if (!r.ok) {
@@ -6526,11 +6542,12 @@
} }
// ── Bootstrap Export / Import ────────────────────────── // ── Bootstrap Export / Import ──────────────────────────
async function exportBootstrap() { async function exportBootstrap(scope) {
scope = scope || 'system';
const status = document.getElementById('bootstrap-status'); const status = document.getElementById('bootstrap-status');
if (status) status.innerHTML = '⏳ Lade...'; if (status) status.innerHTML = '⏳ Lade...';
try { try {
const r = await fetch('/api/brain/memory/export-bootstrap'); const r = await fetch('/api/brain/memory/export-bootstrap?scope=' + encodeURIComponent(scope));
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json(); const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -6538,10 +6555,11 @@
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `aria-bootstrap-${ts}.json`; a.download = `aria-bootstrap-${scope}-${ts}.json`;
document.body.appendChild(a); a.click(); document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} pinned Memories exportiert</span>`; const label = scope === 'system' ? 'System-Regeln' : (scope === 'personal' ? 'persönliche Memories' : 'pinned Memories');
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} ${label} exportiert</span>`;
} catch (e) { } catch (e) {
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`; if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
} }
@@ -6555,7 +6573,11 @@
const text = await file.text(); const text = await file.text();
const bundle = JSON.parse(text); const bundle = JSON.parse(text);
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array'); if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
if (!confirm(`Bootstrap importieren?\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\nALLE aktuell pinned Memories werden überschrieben. Cold Memory bleibt unverändert.`)) { const bScope = bundle.scope || 'all';
const scopeInfo = bScope === 'system' ? 'Nur die aktuell pinned SYSTEM-Regeln werden ersetzt — Persönliches bleibt.'
: bScope === 'personal' ? 'Nur die aktuell pinned PERSÖNLICHEN Memories werden ersetzt — System-Regeln bleiben.'
: 'ALLE aktuell pinned Memories werden überschrieben.';
if (!confirm(`Bootstrap importieren? (scope: ${bScope})\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\n${scopeInfo} Cold Memory bleibt unverändert.`)) {
event.target.value = ''; event.target.value = '';
return; return;
} }