69 KiB
Ausbaustufen — Munin
Was bewusst nicht gebaut wurde, warum, und woran man merkt, dass es Zeit dafür ist.
Der Sinn dieses Dokuments ist, dass niemand — auch nicht wir selbst in einem halben Jahr — dieselben Fragen noch einmal von vorne durchdenken muss. Wo Zahlen stehen, sind sie gemessen, nicht geschätzt; die Messumgebung ist jeweils genannt.
Teil 1 — Entschieden und aufgeschoben
Diese Punkte sind durchgerechnet. Die Entscheidung lautet jeweils „später", nicht „nie".
1.1 FTS5 statt eigenem Suchindex
Stand heute: Ein handgeschriebener BM25-Index im Arbeitsspeicher
(internal/search, 464 Zeilen plus 195 Zeilen Tests), einer je Projekt, beim
ersten Zugriff aufgebaut.
Gemessen (10.000 Einträge à 150 Wörter, Wortschatz 5.000, Zipf-verteilt):
| eigener Index | FTS5 | |
|---|---|---|
| Arbeitsspeicher | 65 MB | 0 |
| Platte | 0 | 54 MB |
| Aufbau | 171 ms | 280 ms |
| Suche | 12 µs | 208 µs |
FTS5 ist im reinen Go-Treiber verfügbar (ENABLE_FTS5), der statische Build
bliebe also erhalten.
Warum aufgeschoben:
- Der Suchindex ist abgeleitete Daten. Er lässt sich jederzeit aus
entriesneu aufbauen — eine Umstellung später bedeutet keine Datenmigration. Der übliche Grund, so etwas früh zu erledigen, greift hier nicht. - Es löst nicht den eigentlichen Engpass. Der ist die semantische Trefferqualität (siehe 2.2), und die kann FTS5 so wenig wie BM25 von Hand.
- FTS5 gibt es nur in SQLite. Der eigene Index ist von der Datenbank unabhängig und hält damit den Weg zu PostgreSQL offen.
Auslöser für die Umstellung:
- Ein Projekt jenseits von grob 50.000 Einträgen (~300 MB Arbeitsspeicher allein für dessen Index)
- Der Start dauert spürbar, weil viele große Projekte gleichzeitig angefragt werden
- Phrasensuche,
NEARoder boolesche Operatoren werden gebraucht — die bringt FTS5 mit, der eigene Index nicht
Aufwand: ein bis zwei Tage. Virtuelle Tabelle mit content=-Verweis auf
entries, Befüllung aus dem Bestand, internal/search entfällt.
1.2 Eine SQLite-Datei je Projekt
Nachtrag: Mit 4.4 gibt es inzwischen eine Datei je Workspace und eine zentrale Kontroll-Datenbank für die Zuordnung Key → Workspace. Die hier besprochene Aufteilung je Projekt bleibt aufgeschoben. Die Messwerte unten stammen aus der Zeit mit nur einer Datei und gelten jetzt je Workspace-Datei.
Stand damals: Eine Datei für alles, zwei Verbindungspools — acht Verbindungen zum Lesen, eine zum Schreiben.
Gemessen:
| Schreibvorgänge über die eine Schreibverbindung | 8.656/s |
| Lesen in Projekt A, ungestört | 82 µs |
| Lesen in Projekt A, während drei Projekte im Dauerlauf schreiben | 121 µs |
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100 Schreibvorgänge/s — etwa 1 % der Kapazität.
Nachmessung über den ganzen Weg (2026-10-06): last_test.go lässt Agenten
über echtes HTTP und MCP arbeiten, mit Key-Prüfung, Protokoll und Suchindex
(Ausführung und alle Ergebnisse in LASTTEST.md). 128 Agenten, je 500 Einträge vorab,
30 % Schreiben, 20 umkämpfte Bezeichner je Projekt, 10 Sekunden, Testrechner
mit 10 Kernen, Clients und Server auf derselben Maschine:
| Aufstellung | remember je s |
remember p50 / p99 |
recall p50 / p99 |
Fehler |
|---|---|---|---|---|
| ein Projekt | 1.427 | 68 ms / 269 ms | 4,5 ms / 23 ms | 0 |
| ein Workspace, 4 Projekte | 1.355 | 73 ms / 286 ms | 4,5 ms / 24 ms | 0 |
| 4 Workspaces, je 1 Projekt | 1.328 | 60 ms / 205 ms | 15 ms / 36 ms | 0 |
- Kein Schreibvorgang ging verloren, der Suchindex kannte in jedem Lauf den letzten Stand.
- Projekte im selben Workspace teilen sich die Schreibverbindung: Mehrere Projekte verhalten sich wie eines. Getrennte Workspaces schreiben etwas schneller, der Rechner insgesamt bleibt aber die Grenze (die Suche wird langsamer, weil alles um dieselben Kerne konkurriert).
- Gegenüber 8.656/s oben kostet der volle Weg etwa das Sechsfache. 1.300 Schreibvorgänge je Sekunde stehen einer Vorgabe von 1.000 je Stunde und Projekt gegenüber (Schreibbudget 3.8). Last ist bei echter Nutzung kein Thema.
Dabei gefunden und behoben: Schrieben zwei Agenten gleichzeitig denselben
Eintrag, konnte der Suchindex eine ältere Fassung behalten als die Datenbank.
Registry.Refresh liest seitdem nach jedem Schreiben aus der Datenbank nach,
statt den mitgegebenen Stand zu übernehmen. Außerdem hat seitdem jedes Projekt
seine eigene Sperre in der Registry: Der Aufbau eines großen Index nach einem
Neustart lässt die anderen Projekte desselben Workspace nicht mehr warten.
Warum aufgeschoben: Es bräuchte trotzdem eine zentrale Datenbank, denn drei
Dinge braucht man, bevor man das Projekt kennt: ResolveKeyHash (Key →
Projekt), ListProjects und EnsureWorkspace. Daraus folgt:
- Globales Protokoll und „Projekte mit Eintragszahl" werden aus einer Abfrage N Abfragen plus Zusammenführen in Go
- Migrationen laufen über jede Datei, auch beim Anlegen eines Projekts
- Verbindungsverwaltung mit Verdrängung nötig (50 Projekte × 9 Verbindungen)
- Kein gemeinsamer Commit über zentrale und Projektdatenbank — „Projekt anlegen samt erstem Key" wäre nicht mehr atomar
Auslöser:
- Anhaltend über ~1.000 Schreibvorgänge/s (10 % der Kapazität)
- Ein Projekt jenseits weniger GB, sodass Sicherung und
VACUUMunangenehm werden - Verbindliche Löschzusagen je Projekt, bei denen „Zeilen gelöscht" nicht reicht
Billigere Schritte davor: mehrere Einträge in einer Transaktion bündeln, WAL-Prüfpunkte seltener setzen.
1.3 Zeichenketten im Suchindex teilen
internal/search hält in docTerms je Dokument eine Liste aller seiner
verschiedenen Wörter als eigene Zeichenketten. Bei 10.000 Einträgen sind das
über eine Million einzeln abgelegte Zeichenketten, obwohl im Projekt vielleicht
nur 20.000 verschiedene Wörter vorkommen.
Die Liste ist nötig: ohne sie müsste beim Ändern oder Löschen der komplette Index durchsucht werden. Sie ließe sich aber verkleinern, indem gleiche Wörter dieselbe Zeichenkette teilen. Geschätzt ein Drittel bis die Hälfte des Speichers.
Warum aufgeschoben: Löst dasselbe Problem wie 1.1, nur halb. Wenn der Speicher wirklich drückt, ist FTS5 der gründlichere Schritt.
1.4 sqlc für die Datenschicht
War empfohlen, dann verworfen: sqlc ist nicht installiert, und ein zusätzlicher
Codegenerator bedeutet, dass go build allein nicht mehr reicht. Handgeschriebenes
database/sql ist außerdem besser lesbar für jemanden, der Go lernt.
Nachrüstbar, ohne eine Zeile SQL zu ändern — das SQL ist bereits so geschrieben, wie sqlc es erwartet.
Teil 2 — Trefferqualität
Hier liegt der eigentliche Engpass. Alles andere in diesem Dokument ist Ressourcenoptimierung; das hier entscheidet darüber, ob ein Agent findet, was er sucht.
2.1 Umlaute im Tokenizer falten — ✅ umgesetzt
tokenize bildet ä→a, ö→o, ü→u und ß→ss ab, beim Indizieren wie beim
Suchen (siehe faltUmlaut in internal/search/index.go).
prüfung ↔ prufung ✓
größe ↔ grosse ✓
prüfung ↔ pruefung ✗
Korrektur zur früheren Fassung dieses Abschnitts: Dort stand, die Umschreibung
mit e (ue, oe, ae) werde mit abgedeckt. Das stimmt nicht. Sie ließe sich nur
abdecken, indem man auch ue zu u zusammenzieht — dann würde aber aus „neue"
ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der Nutzen.
Die Punkte wegzulassen ist ohnehin die weit häufigere Schreibweise.
Gegenüber FTS5 mit remove_diacritics ein Vorteil: Das faltet zwar Umlaute, aber
kein ß — dort findet „grosse" die „Größe" nicht.
2.2 Semantische Suche
Problem: BM25 vergleicht Wörter. Fragt ein Agent „wie deployen wir", findet er einen Eintrag namens „Release-Prozess" nicht — kein Wort passt.
Lösung: Einbettungen (Embeddings) plus Vektorsuche, kombiniert mit BM25 („hybride Suche"): BM25 ist stark bei seltenen, exakten Begriffen — Fehlercodes, Eigennamen —, die Vektorsuche bei Umschreibungen.
Bauform: ein eigener kleiner Dienst, angebunden über HTTP. Python hat dafür das deutlich reichere Ökosystem; die Go-Anwendung bliebe ein Binary.
Aufwand: mehrere Tage, plus laufender Betrieb eines zweiten Dienstes und eines Einbettungsmodells.
Vorher versuchen: die instructions an echten Anfragen schärfen (siehe 3.1).
Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts.
Umgesetzt statt Embeddings (2026-10-14): Die Clients sind selbst Sprachmodelle und kennen Synonyme. Das nutzt Munin jetzt an beiden Enden:
- Beim Suchen:
recallnimmtsynonymsan (höchstens 10), die in derselben Suche mitlaufen. instructions und Werkzeugbeschreibung bitten darum, und ein leeres Ergebnis rät zuerst zur erneuten Suche mit anderen Begriffen. - Beim Schreiben: Einträge enden mit einer Zeile
Auch gesucht als: …. Bewusst im Inhalt statt als Schlagworte: Die Agenten-Vorlagen verlangen Schlagworte sparsam aus dem bestehenden Bestand, und die Zeile bleibt für Menschen sichtbar und bearbeitbar.
Ob Agenten das befolgen, zeigt erst der Betrieb — Stichproben in neuen Einträgen (steht die Zeile da?) sind der einfachste Test.
Lokales Modell statt Gateway: geprüft und verworfen. Das Modell braucht grob 0,5–2 GB Speicher (einmal, nicht je Workspace) und in Go entweder cgo — dann ist der statische Build weg — oder einen zweiten Dienst. Der Zusatznutzen gegenüber Synonymen vom Client ist klein.
2.2a Ausschnitt an der Fundstelle — ✅ umgesetzt (2026-10-14)
entry.Snippet suchte die ganze Anfrage wörtlich. Bei Agenten-Fragen („wie
deployen wir …") traf das nie, der Ausschnitt zeigte immer den Textanfang.
Jetzt: erst die ganze Anfrage, sonst das längste Wort (ab 4 Zeichen), das im
Text vorkommt; in recall samt Synonymen. Hervorgehoben wird dasselbe Wort.
2.3 Lange Einträge zerlegen
get liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern
belegt beim Agenten Kontext, von dem er zwei Absätze braucht.
Lösung: Einträge in Abschnitte zerlegen (an Überschriften) und abschnittsweise zurückgeben, mit Verweis auf den vollständigen Eintrag.
Vorher prüfen: ob überhaupt so lange Einträge entstehen. Bei einem Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne Problem.
2.4 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14)
Umgesetzt in internal/search/varianten.go. Zu jedem Suchwort sucht der Index
mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
| Variante | Beispiel | Gewicht | ab Länge |
|---|---|---|---|
| Wortanfang | „deploy" → Deployment | 0,5 | 3 |
| Endung gekürzt, dann Wortanfang | „deployen" → Deployment | 0,5 | Stamm ≥ 4 |
| Teilwort | „prozess" → Releaseprozess | 0,3 | 5 |
Tippfehler (nur SearchFuzzy) |
„deploymnet" → Deployment | 0,3 | 4 |
Je Eintrag zählt nur die beste Variante eines Suchworts.
Was sich dabei geändert hat:
- Fehler behoben: Der Wortanfang griff früher nur, wenn das Suchwort nirgends exakt vorkam. Stand „deploy" in irgendeinem Eintrag, fand „deploy" den „Deployment-Prozess" nicht mehr.
- Stoppwörter („wie", „wir", „the" …) werden aus der Anfrage entfernt,
nicht aus dem Index. Agenten stellen laut
instructionsganze Fragen; die Füllwörter fanden über den Wortanfang sonst Unpassendes („wir" → „Wirkung"). - Endungen: bewusst kein vollständiger Wortstammbildner, nur
-en,-es,-e,-n,-s. Kein-er, sonst fände „Server" auch „Service". - Tippfehler gleicht nur die Weboberfläche aus (
SearchFuzzy), und nur für Suchwörter, die sonst nichts finden.recallbleibt ohne.
Laufzeit: Der Wortschatz wird jetzt bei jedem Suchwort einmal durchlaufen,
nicht mehr nur beim Fehlschlag. bench_test.go (10.000 Einträge, 20.000
Wörter, Frage mit drei Suchwörtern): 0,37 ms vorher, 1,7 ms nachher, gut die
Hälfte davon Speicherbereinigung. Für Agenten wie für die Vorschläge beim
Tippen unerheblich.
Offen: Synonyme („Release" ↔ „Deployment") kann das nicht — dafür wäre 2.2 nötig. Und ob die Gewichte gut gewählt sind, zeigt erst der Betrieb.
Teil 3 — Funktionen für Agenten
3.1 Die instructions an echten Anfragen schärfen
Der Text in internal/mcpserver/server.go steht bei jedem Client im Kontext und
entscheidet darüber, ob ein Agent vor dem Schreiben sucht oder blind Dubletten
anlegt.
Vorgehen: Nach ein paar Wochen Betrieb ins Protokoll schauen — wie viele
remember-Aufrufe legen neu an, wie viele aktualisieren? Ein hoher Anteil
Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
Kostet nichts und wirkt sofort. Deshalb steht es hier ganz oben.
Messgrundlage ✅ (2026-10-14): Jeder recall landet im Suchprotokoll
(search_log, migrations/006, 30 Tage Aufbewahrung). Die Nutzungsseite zeigt
den Anteil der Suchen ohne Treffer, den Anteil mit Synonymen und die häufigsten
Anfragen ohne Treffer. Bewusst eine eigene Tabelle statt audit_events: Suchen
sind viel häufiger als Schreibvorgänge und würden das Protokoll zudecken, und
Anfragen sind Inhalt — sie gehen mit dem Projekt und haben eine kürzere Frist.
3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle Felder im Kopf, ein Einlesen wäre also möglich.
Wozu: Umzug auf einen anderen Server, Wiederherstellung aus der Sicherung, Erstbefüllung aus vorhandener Dokumentation.
Aufwand: ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert — überschreiben, überspringen oder abbrechen.
✅ Umgesetzt 2026-09-25:
- Weg: ZIP-Upload unter
/p/{projekt}/import, Rolleadmin. Bewusst kein Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können Projekt-Admins so selbst importieren. - Zusammenstoß: überspringen (Standard) oder überschreiben. Beim Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der importierende Zugang. Ein Abbrechen gibt es nicht, dafür eine Vorschau, die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden, werden nie geschrieben. Ein wiederholter Import ist damit harmlos.
- Papierkorb: Im Modus überspringen holt ein Import keinen gelöschten
Eintrag zurück. Das war die Falle, weil
AllEntriesden Papierkorb nicht sieht. - Felder: Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide
Zeitstempel erhalten (neu:
EntryWrite.CreatedAt/UpdatedAt). Sonst sähe nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der Verlauf wird nicht übernommen, der Export enthält ihn nicht. - Fremde Dateien: Ohne Kopf gilt der Dateiname als Bezeichner und die
erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen,
soweit sie
titleundtagsbetreffen. Kein YAML-Paket, ein kleiner eigener Leser ininternal/importer/parse.go. - Grenzen: 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt. Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv behauptet.
- Protokoll: ein Eintrag
entry_importje Import mit Zusammenfassung, nicht einer je Datei. So verzerrt ein einmaliger Import die Nutzungsübersicht (4.6) nicht. - Nebenbei behoben: Das 1-MB-Limit für Formulare lag über dem ganzen Mux,
auch über
/mcp. Zwei geschachtelteMaxBytesReaderbegrenzen auf den kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, undMAX_API_BODY_BYTESwirkte nur nach unten. Jetzt wähltLimitBodiesdie Grenze einmal außen je Route.
3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14)
Umsetzung: internal/mcpserver/dubletten.go. Bei remember mit neuem
Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich),
sondern der Anteil gemeinsamer Titelwörter (search.Ueberdeckung, in beide
Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile
„Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den
„Deployment-Prozess", wenn dort „release" steht.
Ab 50 % Überdeckung: nichts schreiben, judgment_required mit bis zu fünf
candidates zurückgeben, kein Fehler. force: true legt trotzdem an.
Aktualisieren (Bezeichner vorhanden) wird nie geprüft.
Bekannter Fehlalarm: „Server-Zugang" gegen „Datenbank-Zugang" (50 %).
Kostet den Agenten einen zweiten Aufruf mit force. Ob die Schwelle passt,
zeigt der Betrieb.
Ursprüngliche Beschreibung:
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da.
Lösung: Bei remember mit neuem Bezeichner den vorhandenen Suchindex über
Titel und Inhalt laufen lassen. Liegt ein Treffer über einer Schwelle, den
Eintrag nicht anlegen, sondern die Kandidaten zurückgeben und den Agenten
entscheiden lassen:
{
"created": false,
"judgment_required": true,
"candidates": [
{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "score": 0.81 }
],
"hint": "Passt einer davon? Dann mit dessen slug erneut remember aufrufen.
Sonst mit force: true anlegen."
}
Der entscheidende Punkt ist, die Entscheidung zurückzugeben statt sie zu treffen. Ein Agent, der einen Hinweis bekommt, korrigiert sich; einer, dem der Aufruf stillschweigend abgelehnt wird, versucht es mit einem noch schlechteren Bezeichner.
Braucht keine Einbettungen — der BM25-Index liegt bereits vor.
Vorbild: Engram macht genau
das über FTS5 (candidates[] plus judgment_required bei mem_save). Das Muster
ist gut und ließe sich mit unserem Index eins zu eins übernehmen.
3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14)
Problem: remember überschrieb kuratierte Einträge ohne Rückfrage; der
Haken fiel still weg. Weder der Agent noch ein Mensch bekam es mit, und eine
Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er
aktuell ist).
Umsetzung (internal/mcpserver/kuratiert.go), Muster wie bei 3.3:
rememberauf einen kuratierten Eintrag schreibt nicht, sondern meldetjudgment_requiredmit Handlungsanweisung.- Mit
override_curated: trueundreasonwird geschrieben. Der Haken fällt weg (der neue Text ist ungeprüft), das Protokoll vermerktkuratiert aufgehoben: <Begründung>— zählt in der Nutzung als Aktualisierung. - Die Nutzungsseite listet solche Einträge unter Zur Prüfung, bis ein Mensch
den Haken neu setzt (
store.CuratedOverrides, keine eigene Tabelle). - instructions: Kuratiertes hat Vorrang vor eigenen Annahmen.
- Suche: kuratierte Einträge mit Faktor 1,2 (
curatedBoostininternal/search/registry.go) — genug für den Gleichstand, zu wenig, um einen klar besser passenden Eintrag zu verdrängen.
Bewusst nicht: Sperren mit Änderungsvorschlägen. Erst sinnvoll, wenn die Liste Zur Prüfung zeigt, dass kuratierte Einträge oft falsch überschrieben werden.
Offen: forget auf kuratierte Einträge hat noch keine Hürde. Weniger
dringend — der Papierkorb hält 30 Tage, und nur Menschen holen zurück —, aber
dasselbe Muster würde passen.
3.4 Bezeichner vorschlagen lassen
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
raten. Ein Werkzeug suggest_slug(thema) würde ihm die Arbeit abnehmen:
- gibt es zum Thema bereits einen Eintrag → dessen slug zurückgeben
- sonst einen neuen vorschlagen, der zu den vorhandenen passt
Damit wird aus „hoffentlich wählt das Modell einen guten Bezeichner" ein
Nachschlagevorgang. Aufwand gering, die Bausteine (Suche, Slugify) liegen vor.
Vorbild: Engrams mem_suggest_topic_key.
3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
demselben Gewicht aus recall zurück.
Kleiner Schritt zuerst: Das Änderungsdatum steht bereits in jeder Antwort, wird aber nicht betont. Einträge jenseits einer Frist zusätzlich mit einem sichtbaren Vermerk versehen:
{ "slug": "deployment-prozess", "updated_at": "2024-03-11",
"stale": true, "hint": "Seit 18 Monaten unverändert — vor Verwendung prüfen." }
Größerer Schritt: eine Prüffrist je Eintrag, die nach Art unterschiedlich schnell abläuft — ein Zugangsverfahren altert schneller als eine Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein Bestätigen, das die Frist zurücksetzt.
✅ Kleiner Schritt umgesetzt 2026-09-25:
- Regel:
Entry.Staleininternal/store/models.go. Ein Eintrag, der seit über einem Jahr (StaleAfter) nicht geändert wurde, gilt als veraltet. Das gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, wann geprüft wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag liest bald niemand mehr. - MCP:
recall,listundrelatedtragen"stale": true,getzusätzlich einenhintmit Handlungsanweisung.recallnennt im Hinweis, wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema sein. Dieinstructionserklären das Feld. - Oberfläche: Auf der Eintragsseite steht die Marke über ein Jahr alt.
- Offen: der größere Schritt (Frist je Art, „fällige Einträge“, Bestätigen).
Vorbild: Engram führt dafür review_after und expires_at je Beobachtung,
mit einer Verfallslogik je Typ, sowie ein mem_review zum Abarbeiten. Deutlich
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
3.6 Änderungshistorie — ✅ umgesetzt
entries.version zählt hoch, aber alte Fassungen wurden nicht aufgehoben.
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
Umgesetzt: Tabelle entry_versions
(migrations/004_aenderungshistorie.sql). entries bleibt die aktuelle
Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
beim erstmaligen Anlegen), sichert snapshotEntryVersion den bisherigen
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
Läuft in derselben Transaktion wie die Änderung selbst, sowohl beim normalen
Schreiben (PutEntry) als auch bei der Sammelbearbeitung
(BulkUpdateTags, siehe 5). Löschen eines Eintrags nimmt seine Historie über
ON DELETE CASCADE mit — konsistent mit Tags und Verweisen.
Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin
möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn EXPORT_DIR
gesetzt ist, während die Historie in der Datenbank immer mitläuft.
3.7 Verwandte Einträge — ✅ umgesetzt
Der Verweisgraph (entry_links) wird bisher nur für Rückverweise genutzt. Ein
Werkzeug related(slug), das Nachbarn im Graphen liefert, wäre für einen Agenten
ein zweiter Weg zum Kontext — neben der Suche.
Aufwand: gering, die Daten liegen vor.
✅ Umgesetzt 2026-09-25: MCP-Werkzeug related(slug, distance?, limit?).
Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise
ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt
Abstand, den Eintrag, über den er gefunden wurde (via), die Richtung und
stale. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als
Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In
einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem
zusammen. get liefert weiterhin links_to/linked_by als nackte
Bezeichner. related ergänzt Titel, Datum und die zweite Reihe.
3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe APIAuth in
internal/web/middleware.go) — richtig für Menschen hinter einer gemeinsamen
Adresse, aber eine Schleife im Agenten hatte damit kein Limit.
Umgesetzt als auth.WriteBudget: ein Fixed-Window-Zähler über schreibende
MCP-Aufrufe, Vorgabe 1.000 je Stunde (WRITE_LIMIT_HOURLY), durchgesetzt in
Handler.requireWrite in internal/mcpserver/tools.go. Lesen bleibt
unbegrenzt.
Korrektur zum ursprünglichen Vorschlag: Dort stand „je Key und Stunde". Das
Budget hängt jetzt am Projekt. Der Grund: Ein Limit je Key lässt sich
umgehen, indem man einen zweiten Key anlegt — und das darf jeder Projekt-Admin
selbst. Es hätte also nur vor dem Versehen geschützt, nicht vor dem Verbrauch.
Einzelne Projekte bekommen unter /admin/projects eine eigene Obergrenze; setzen
darf sie nur der Plattform-Admin, aus demselben Grund.
Drei Dinge, die beim Bauen auffielen:
-
Die Obergrenze darf nicht zwischengespeichert werden. Die MCP-Server je Projekt sind Closures über eine Projektzeile, die beim ersten Zugriff gelesen wurde — eine Änderung in der Verwaltung wäre dort nie angekommen. Genommen wird deshalb
sess.Project.WriteLimitHourlyaus der Sitzung: Die hatAPIAuthfür genau diesen Request frisch aus der Datenbank geholt. Aktuell, und ohne zusätzliche Abfrage im Schreibpfad. -
Jede Ablehnung zu protokollieren hätte das Problem nur verschoben. Ein Agent in einer Schleife hätte statt der Einträge das Protokoll vollgeschrieben. Deshalb gibt
Consumeals dritten Wert zurück, der wievielte Aufruf über der Grenze das war — vermerkt wird nur der erste je Fenster. -
Die Fehlermeldung geht an ein Sprachmodell, nicht an einen Menschen. Ein nackter Fehler führt dazu, dass es sofort wieder versucht oder auf ein anderes Werkzeug ausweicht. Die Meldung sagt darum ausdrücklich, was zu tun ist: abbrechen und das Limit melden.
Was das Budget nicht ist: Der Zählerstand liegt im Arbeitsspeicher und ist nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört der Zähler in die Datenbank.
3.9 Papierkorb statt endgültigem Löschen — ✅ umgesetzt
forget löscht heute sofort und endgültig, samt aller Fassungen (3.6,
ON DELETE CASCADE). Das gilt auch, wenn ein Agent es aufruft. Überschreiben
lässt sich über den Verlauf reparieren, Löschen nicht. Damit ist Löschen der
schlimmste Fall, den ein fehlgeleiteter Agent heute anrichten kann.
Lösung: Löschen setzt nur eine Markierung (deleted_at, deleted_by).
Markierte Einträge verschwinden aus Suche, Liste, Graph und MCP-Antworten,
bleiben aber samt Verlauf in der Datenbank. Eine Seite Papierkorb je Projekt
zeigt sie. Menschen mit Schreibrecht können sie dort wiederherstellen oder
endgültig löschen. Nach einer Frist (Vorschlag 30 Tage) räumt die tägliche
Aufgabe sie endgültig weg, im selben Lauf wie das Protokoll (siehe 4.4,
WP12).
Zu bedenken:
- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt. Schreibt jemand mit demselben Bezeichner neu, muss klar sein, was passiert. Vorschlag: Das stellt den Eintrag wieder her und überschreibt ihn, wobei der alte Stand wie gewohnt in den Verlauf wandert.
- Jede Abfrage auf
entriesbraucht die Bedingungdeleted_at IS NULL. Das ist dieselbe Art Risiko, die 4.4 für Workspaces bewusst vermeiden wollte. Hier ist es vertretbar, weil ein vergessenesWHEREnur einen gelöschten Eintrag des eigenen Projekts zeigt. Trotzdem gehört ein Test dazu, der jeden lesenden Weg (Suche, Liste,get,recall, Graph, Rückverweise, Export) prüft.
Aufwand: mittel.
✅ Umgesetzt 2026-09-24:
- Schema:
migrations/005_papierkorb.sql:deleted_at(NULL = normal),deleted_byund ein Teilindex nur über den Papierkorb. - Store:
internal/store/papierkorb.go.DeleteEntrygibt es nicht mehr, an seine Stelle trittMoveEntryToTrash. Der neue Name sorgt dafür, dass jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nurPurgeFromTrashundPurgeTrash, und beide fassen nur an, was schon im Papierkorb liegt. - Lesewege: Alle tragen
nichtImPapierkorb.ListTagsschließt die Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen.TestPapierkorbUnsichtbarprüft jeden Weg einzeln,TestForgetMovesToTrashdasselbe aus Sicht eines Agenten (get,recall,list). - Gleicher Bezeichner: Wie vorgeschlagen holt
PutEntryden Eintrag zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf (Entry.RestoredFromTrash). Web undremembersagen das dem Schreiber, das Protokoll vermerktentry_restore. Ausnahme: Wer eine gelesene Fassung mitschickt (ExpectedVersion), bekommt einen Konflikt. Das ist der Fall „Formular offen, währenddessen hat ein Agent gelöscht“, und eine Löschung soll nicht unbemerkt rückgängig werden. - MCP:
forgetlegt in den Papierkorb und sagt das in Beschreibung und Antwort. Wiederherstellen oder endgültig löschen gibt es über MCP bewusst nicht.remembererkennt „angelegt“ jetzt an der Fassung nach dem Schreiben statt über eine Nachfrage vorher, die den Papierkorb nicht gesehen hätte. - Oberfläche:
/p/{project}/trash, nur mit Schreibrecht, auch das Ansehen: Gelöscht wird oft, was nicht jeder sehen soll. Es gibt Wiederherstellen und endgültiges Löschen je Zeile oder für eine Auswahl, dazu einen Filter nach löschendem Zugang mit Alle … wiederherstellen für den Fall „Agent hat zu gründlich aufgeräumt“.confirm.jsliest die Rückfrage jetzt auch am gedrückten Knopf (event.submitter). - Frist:
store.TrashRetention= 30 Tage.papierkorbLeerenAlleinmain.goläuft im selben täglichen Takt wie das Protokoll-Aufräumen und schreibt je Eintrag eine Protokollzeile mit Herkunftsystem. Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie dort mitziehen. - Protokoll: Neue Aktionen
entry_restoreundentry_purge.entry_deletebedeutet jetzt „in den Papierkorb“ (Detail sagt es).
Teil 4 — Betrieb
4.1 Zugriffsprotokoll — ✅ umgesetzt
LogRequests in internal/web/middleware.go schreibt je Anfrage eine Zeile:
200 POST /mcp 1.475ms claude-desktop
202 POST /mcp 149µs claude-desktop
401 POST /mcp 193µs —
Statische Dateien und der Healthcheck bleiben draußen (Rauschen), die Query-Zeichenkette ebenfalls — dort stehen Suchbegriffe, und die sind Inhalt, kein Betriebsdatum.
Zwei Fallstricke, die beim Bauen auffielen:
-
Der Mantel um den
ResponseWriterverdecktehttp.Flusher. Der MCP-Transport kommt überhttp.ResponseControllerund damit überUnwrap()heran, die verbreitetere direkte Typzusicherungw.(http.Flusher)hätte aber lautlos fehlgeschlagen — der Aufrufer nimmt dann an, Leeren werde nicht unterstützt. Deshalb istFlush()ausdrücklich implementiert. -
Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des Requests. Gelöst über einen veränderbaren Halter im Context (
aktorHalter), den die Anmelde-Middleware befüllt.
4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt
.github/workflows/test.yml prüft bei jedem Push und jedem Pull Request:
Formatierung, go vet, go test -race und einen Bau mit CGO_ENABLED=0.
-race ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools
werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl,
sobald jemand eine Abhängigkeit hinzufügt, die C braucht — das statische Binary
ist die Grundlage des ganzen Deployments.
4.3 Keycloak statt Key-Anmeldung
Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf
api_key_id, nicht auf Klarnamen. Kommt eine Nutzerverwaltung dazu, lässt sich
user_id danebenstellen, ohne die Historie zu verlieren.
Zu ergänzen: Tabellen users und memberships, OIDC-Anmeldung, Rollen an
Personen statt an Keys.
4.4 Mehrere Workspaces — ✅ umgesetzt
Ausgangslage: Die Spalte workspace_id stand von Anfang an in allen Tabellen,
es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald
Abteilungen sich gegenseitig nicht sehen sollen.
Entscheidung: Abteilungen dürfen sich strukturell nie sehen — nicht nur "in
der Praxis meistens nicht". Eine gemeinsame Tabelle mit Filterung über
WHERE workspace_id = ? hängt davon ab, dass diese Bedingung in jeder
gegenwärtigen und jeder künftigen Abfrage korrekt steht — bei einem einzelnen
Entwickler ohne Review durch Dritte ein zu hohes Risiko für ein einziges
vergessenes WHERE.
Gleichzeitig muss der Betrieb zentral bleiben: ein Deployment für anfangs 10, später bis zu 40 Teams (1–5 Projekte je Team) statt eines eigenen Deployments pro Abteilung, das niemand von Hand pflegen kann. Eigene Backups je Team sind Pflicht.
Konzept: eine dünne Kontroll-Datenbank (Workspaces, Key-Hash → Workspace-Routing) plus eine eigene SQLite-Datei je Workspace mit dem heutigen, unveränderten Schema. Trennung ist damit strukturell (eigene Datei), nicht durch Disziplin in jeder Abfrage erzwungen. Backups werden dadurch nebenbei trivial — eine Datei je Team lässt sich einzeln sichern.
Bei 10–40 Workspaces ist das unproblematisch — deutlich unterhalb der in 1.2 genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde (die galt für Projekte, nicht für Workspaces, und für eine viel höhere Zahl).
Arbeitspakete:
- ✅ Kontroll-Schema:
workspaces+key_index(hash → workspace_id) —internal/store/migrations_control/001_init.sql - ✅
ControlStore: kleines Store-Objekt für die Kontroll-DB —internal/store/control.go - ✅ Key-Routing zweistufig:
ControlStore.ResolveKeyWorkspace(hash → workspace_id) davor, danach die heutigeResolveKeyHash-Logik unverändert auf der passenden Workspace-Datei — sowohl beim Web-Login (handlers_auth.go) als auch bei MCP-Aufrufen (APIAuthinmiddleware.go).CreateAPIKey/RevokeAPIKeypflegenkey_indexim selben Zug. - ✅ Verbindungscache je Workspace:
internal/workspace.Manageröffnet Workspace-Dateien bei Bedarf und hält sie offen (map[workspaceID]*Workspace, einsync.Mutex). Bei 10–40 Workspaces keine Verdrängung nötig. - ✅ Workspace-Auflösung pro Request ersetzt das feste
WorkspaceIDinweb.Config: Das Session-Subject trägt die Workspace-ID direkt (key:<workspace-id>:<key-id>bzw.platform:<workspace-id>, siehesessionFromSubjectinmiddleware.go) — signiert, also fälschungssicher, und ohne Umweg über die Kontroll-DB bei jedem Request. Für den Plattform-Admin (ein globalesADMIN_TOKEN, siehe Entscheidung oben) kommt die Workspace-Wahl über/admin/workspacesdazu (handlers_workspaces.go) — eine frische Admin-Sitzung ohne gewählten Workspace kommt an keine Projektseite,requireWorkspaceinmiddleware.goerzwingt das. MCP-Werkzeuge lesen ihren Workspace aus dem Context (web.WorkspaceFromContext, siehemcpserver/tools.go) statt aus einer festen Handler-Instanz — einmcpserver.Handlerbedient dadurch beliebig viele Workspaces gleichzeitig. - ✅ Migrationen laufen für die Kontroll-DB und beim Öffnen jeder
Workspace-Datei (
store.Open/store.OpenControl,main.golegtDATA_DIR/control.dbundDATA_DIR/workspaces/<id>.dban) - Datenmigration für eine bereits befüllte Bestandsinstallation (einmaliges Skript) — bislang nicht nötig, es läuft noch keine produktive Instanz
- ✅ Workspace-Verwaltung, vollständig:
/admin/workspaceszeigt alle Workspaces (neueste zuerst, mit Anlegedatum und einer "Aktuell"-Markierung), legt neue an (springt danach direkt zur Projektverwaltung, kein Umweg über die Auswahl) und verlinkt je Zeile auf/admin/workspaces/{id}/editzum Umbenennen. Dort auch die Gefahrenzone: Löschen entfernt erst die Kontroll-Zeile (samtkey_indexüberON DELETE CASCADE), dann die Workspace-Datei von der Platte (workspace.Manager.Delete) — mit Bestätigung durch Eintippen des Namens, wie beim Löschen eines Projekts. Betrifft das den Workspace der eigenen Sitzung, wird die Sitzung auf "kein Workspace gewählt" zurückgesetzt statt den Admin auszusperren. Dazu ein Workspace-Umschalter in der Kopfleiste (nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die Workspace-Umschalter von Slack/Linear/Notion (templates/partials.html,.arbeitsbereich*instatic/style.css). - ✅
scripts/backup.sh: sichert Kontroll-DB und jede Workspace-Datei einzeln übersqlite3 .backupin einen zeitgestempelten Ordner, optionale Aufräumfrist überRETENTION_DAYS. README verlinkt es samt Cron-Beispiel. - ✅ Tests:
internal/store/control_test.go(ControlStore/Routing),internal/workspace/workspace_test.go(Manager cached, trennt Dateien, schließt sauber),internal/web/web_test.go(TestWorkspaceIsolation— zwei gleichzeitig offene Workspace-Dateien im selben Prozess kommen sich nicht in die Quere) - ✅ README,
.env.example,Dockerfile:DB_PATH→DATA_DIR
Stand: Alle Arbeitspakete umgesetzt bis auf eines: eine Datenmigration für eine bereits befüllte Bestandsinstallation, falls die je nötig wird (7, bislang läuft keine produktive Instanz). Mehrere Workspaces gleichzeitig in einem Prozess, strukturell getrennt, mit Oberfläche zum Anlegen und Wechseln und eigenem Backup-Skript — fertig und end-to-end geprüft (Tests plus manueller Durchlauf gegen den laufenden Server bzw. das Skript).
Nachgezogen (beim Abgleich der Doku mit dem Code am 2026-09-24 gefunden und am selben Tag behoben):
- ✅ Wiederkehrende Aufgaben über alle Workspaces. Vorher bekamen
pruneAuditRegularlyundexportRegularlynur den ältesten Workspace. Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1) griff dort nicht. Jetzt gehen beide in jedem Durchgang überfuerJedenWorkspaceinmain.go: Die Liste kommt jedes Mal frisch aus der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber gesammelt gemeldet (errors.Join). Tests:main_test.go. - ✅ Export je Workspace. Vorher war der Exporter beim Start fest an den Store
des ältesten Workspace gebunden (Jetzt exportieren in einem anderen
Workspace schrieb nichts), und die Zielordner hießen nur
EXPORT_DIR/<projekt>, sodass sich Workspaces mit gleichem Projekt-Bezeichner gegenseitig überschrieben und Dateien wegräumten. Jetzt kenntexport.Exporterkeine Datenbank mehr, der Aufrufer gibt den Store je Lauf mit (ExportWorkspace). Die Ordner heißenEXPORT_DIR/<workspace-id>/<projekt>/, dazu_workspace.mdmit dem Namen. Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner erzeugt../munin -exportexportiert alle Workspaces und endet mit Fehlercode, wenn einer scheitert. Tests:TestExportTrenntWorkspaces,TestExportKnopfImZweitenWorkspace,main_test.go.
Alte Exportordner direkt unter EXPORT_DIR/ fasst der neue Export nicht mehr
an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden.
4.5 Kennzahlen
Ein /metrics-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt
Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der Oberfläche. Die Hilfe (Mit Agenten arbeiten) rät, zu prüfen, ob Aktualisierungen gegenüber Neuanlagen zunehmen. Das lässt sich heute nur mühsam aus dem Protokoll ablesen.
Inhalt einer kleinen Übersichtsseite je Projekt:
- Schreibvorgänge der letzten 30 Tage, aufgeteilt in Neuanlagen und Aktualisierungen, getrennt nach Mensch und Agent
- Anteil kuratierter Einträge
- Einträge ohne Schlagwort, Verweise auf nicht vorhandene Einträge
- zuletzt aktive Zugänge
Für den Plattform-Admin dieselben Zahlen als Spalten in /admin/projects. So
fällt auf, welches Team Munin gar nicht benutzt.
Voraussetzung — ✅ erledigt (2026-09-24): Das Protokoll vermerkt bei
entry_write im Feld detail, ob der Eintrag angelegt oder aktualisiert
wurde (store.EntryWriteDetail, Konstanten DetailEntryCreated und
DetailEntryUpdated in internal/store/audit.go), sowohl bei remember als
auch in der Weboberfläche. Maßgeblich ist die Fassung nach dem Schreiben
(1 = angelegt), das stimmt auch bei gleichzeitigem Schreiben. Die
Sammelbearbeitung schreibt weiterhin ihren eigenen Text und zählt als
Aktualisierung. Schreibvorgänge vor diesem Datum lassen sich nicht
unterscheiden.
Aufwand: gering bis mittel, die Daten liegen im Protokoll und in entries.
✅ Umgesetzt 2026-09-24:
- Seite:
/p/{project}/usage(handlers_nutzung.go), für jede Rolle. Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle sieht. - Zahlen:
store.UsageSummaries(internal/store/nutzung.go) liefert Schreibvorgänge der letzten 30 Tage (UsageWindow) aus dem Protokoll und den Bestand ausentries, für ein Projekt oder alle, in zwei Abfragen. Es gibt keine eigene Zähltabelle, die auseinanderlaufen könnte. - Mensch oder Agent: Unterschieden wird nach Weg: Web = Mensch, MCP = Agent. Ein Mensch, der selbst über MCP schreibt, zählt als Agent.
- Neu oder aktualisiert: Maßgeblich ist
detail.angelegtist neu; alles andere außer leer ist eine Aktualisierung („aktualisiert“, „wiederhergestellt: …“, „Sammelbearbeitung: …“). Leer heißt „ohne Angabe“: Das sind Zeilen aus der Zeit vor der Unterscheidung, die nach 30 Tagen von selbst herausfallen. - Der entscheidende Wert: der Anteil der Aktualisierungen, mit einem Hinweis zur Deutung. Eine feste Schwelle „gut/schlecht“ gibt es bewusst nicht, denn ein junges Projekt legt naturgemäß vor allem an.
- Weitere Abschnitte: Verweise ins Leere (meistgenannte zuerst, mit
Schreibrecht „anlegen“ über
/new?slug=…), Einträge ohne Schlagwort, die fünf zuletzt aktiven Zugänge. - Admin:
/admin/projectshat die Spalten Schreibvorgänge (30 Tage, mit „keine“ als deutlichem Signal) und Zuletzt geändert. Die Eintragszahl kommt jetzt aus derselben Abfrage statt aus einemCountEntriesje Projekt.
4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt
last_used_at wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem
Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben,
oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
Lösung, in zwei Stufen:
- Unter Zugänge Keys markieren, die seit mehr als 90 Tagen (oder nie) benutzt wurden, mit einem Knopf zum Zurückziehen. Gering.
- Optional ein Ablaufdatum beim Anlegen. Abgelaufene Keys gelten wie
zurückgezogene. Braucht eine Migration (
expires_at) und eine Prüfung inResolveKeyHash. Mittel.
✅ Stufe 1 umgesetzt 2026-09-25:
- Regel:
APIKey.Unusedininternal/store/models.go. Ein aktiver Key gilt als ungenutzt, wenn er seit mehr als 90 Tagen (UnusedAfter) nicht benutzt wurde. Wurde er noch nie benutzt, zählt das Anlegedatum. Sonst stünde jeder frische Key sofort als ungenutzt da. Zurückgezogene Keys sind nie ungenutzt. - Oberfläche: Unter Zugänge trägt so ein Key die Marke ungenutzt, über
der Tabelle steht ein Hinweis mit der Anzahl. Den Knopf zum Zurückziehen gab
es schon. Keine Migration,
last_used_atreicht. - Unschärfe:
TouchAPIKeyschreibt höchstens einmal pro Stunde. Für eine Grenze von 90 Tagen spielt das keine Rolle. - Tests:
TestAPIKeyUnused(Grenzfälle),TestKeyListFreshKeyNotUnused. - Offen: Stufe 2 (Ablaufdatum).
4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30)
Bis dahin legte nur der Plattform-Admin (ADMIN_TOKEN) Projekte an. Bei 10–40
Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
Lösung: Eine Rolle „Workspace-Admin“, die im eigenen Workspace Projekte anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt).
Entscheidung (2026-09-30): ohne Keycloak umsetzen. Ursprünglich stand hier,
4.8 nur zusammen mit 4.3 zu entscheiden. Keycloak wird aktuell aber nicht
gebraucht, das Nadelöhr beim Plattform-Admin dagegen schon. Kommt 4.3 doch,
fallen nur die Token-Tabelle und der Anmeldezweig weg. Die Rechteprüfungen
(Session.CanManageProjects, CanEditWriteBudget, CanImport, die Routen)
bleiben gleich, nur die Herkunft von IsWorkspaceAdmin ändert sich.
Umsetzung:
- Eigene Token-Art
wa_…in der Kontroll-Datenbank (workspace_admins,migrations_control/002), Hash statt Klartext, zurückziehbar, mit letzter Nutzung. Mehrere je Workspace, jeder mit Namen der Person, damit das Protokoll sagt, wer etwas getan hat. - Nur Weboberfläche:
APIAuthakzeptiert nurwk_…(LooksLikeKey). Einwa_-Token scheitert dort schon an der Form und liegt außerdem in einer anderen Tabelle. - Eigenes Feld
Session.IsWorkspaceAdminstattIsPlatformAdminmitzunutzen. Jede Prüfung aufIsPlatformAdminweist ihn damit von selbst ab. Freigegeben ist nur, was dieCan…-Methoden ausdrücklich nennen. - Darf: Projekte des eigenen Workspace anlegen, bearbeiten, löschen. Zugänge
aller Projekte dort verwalten. Alle Projekte lesen, dazu Protokoll, Nutzung
und
/admin/audit. - Darf nicht: Einträge schreiben, importieren, Papierkorb. Außerdem Schreibbudget (der Server ignoriert das Feld, es wird also nicht nur im Template ausgeblendet), Export, Workspaces, andere Workspace-Admins anlegen.
- Anlegen und Zurückziehen nur durch den Plattform-Admin, auf der Bearbeitungsseite des Workspace. Beides und jede Anmeldung stehen im Protokoll des Workspace.
- Ungenutzte Workspace-Admins markiert wie bei den Keys (4.7, dieselbe Regel
über
store.ungenutzt). Die Workspace-Übersicht zeigt je Workspace die Zahl der aktiven und der ungenutzten Workspace-Admins.
Bewusst hingenommen: Wer Zugänge verwalten darf, kann sich einen
admin-Key für jedes Projekt seines Workspace anlegen und damit auch
schreiben. Das Verbot, Einträge zu schreiben, ist also keine harte Grenze. Es
sorgt dafür, dass Änderungen über Keys laufen und im Protokoll eindeutig
zuzuordnen sind. Die Rolle gehört deshalb nur an Personen, denen die Abteilung
ohnehin alle ihre Projekte anvertraut (so steht es auch in der Hilfe).
Tests: internal/web/workspace_admin_test.go (darf / darf nicht,
Workspace-Trennung, MCP abgewiesen, Zurückziehen beendet die Sitzung),
TestWorkspaceAdmins, TestWorkspaceAdminsVanishWithWorkspace,
TestWorkspaceAdminUnused, TestWorkspaceListCountsActiveAdmins,
TestWorkspaceAdminTokenIsNoKey.
4.9 Zugriff über ein Projekt hinaus — aufgeschoben
Zwei verwandte Wünsche, besprochen am 2026-09-25 und bewusst aufgeschoben:
- Öffentliches Projekt: Ein Projekt, das jeder gültige Key lesen darf, auch aus anderen Workspaces. Etwa für firmenweite Richtlinien.
- Workspace-Zugang: Ein Key für alle Projekte eines Workspace, mit einem MCP-Server statt einem je Projekt. Für Teams, die projektübergreifend arbeiten und Wissen aus mehreren Projekten brauchen.
Stand heute: Ein Key gehört zu genau einem Projekt, und jedes Projekt hat einen eigenen MCP-Server, dessen Werkzeuge fest an dieses Projekt gebunden sind. Wer mehrere Projekte braucht, trägt mehrere Server ein, je einen Key pro Projekt. Das Routing Key → Workspace in der Kontroll-Datenbank (4.4) kennt dagegen schon keine Projektbindung.
Geht schon heute, ohne Code: Für ein „öffentliches“ Projekt bekommt jedes
Team einen read-Key und trägt das Projekt als zweiten Server ein. Das kostet
Verwaltung, aber keine Entwicklung, und das Protokoll zeigt je Team, wer liest.
Öffentliches Projekt
| Idee | Aufwand | |
|---|---|---|
| A (empfohlen) | Eigener Endpunkt /mcp/public/<projekt>, jeder gültige Key darf lesen (recall, get, list, related), schreiben nur mit Keys des Projekts. Schalter nur für den Plattform-Admin. |
2½–3½ Tage |
| B | recall sucht automatisch zusätzlich im öffentlichen Projekt |
4–6 Tage, ändert den MCP-Vertrag |
| Lesen in der Oberfläche | Menschen aus anderen Projekten sehen es im Browser | +2–3 Tage |
Von Schreibrecht für alle wird abgeraten: Das Schreibbudget (3.8) gälte für die ganze Firma zusammen, und niemand fühlte sich für die Qualität zuständig.
Workspace-Zugang
Nötige Änderungen: Workspace-Keys ohne Projektbindung (api_keys.project_id
ist heute NOT NULL, bei SQLite heißt das Tabelle neu aufbauen),
Session.CanAccessProject erweitern, Verwaltung unter /admin/workspaces, und
auf der MCP-Seite eine von zwei Stufen:
| Idee | Aufwand gesamt | |
|---|---|---|
| Stufe 1 | Projekt wählbar über den Pfad (/mcp/<projekt>), ein Key für alle. Löst den Zugriff, der Agent muss aber weiter wissen, wo er sucht. |
4½–6 Tage |
| Stufe 2 | Ein Server je Workspace, recall über alle Projekte, jeder Treffer nennt sein Projekt, get/related/remember bekommen einen Parameter project. |
7½–10 Tage |
Risiken:
- Sicherheit: Ein verlorener Workspace-Key öffnet alle Projekte statt einem. Die Grenze zwischen Workspaces bleibt strukturell dicht (eigene Datei je Workspace). Innerhalb eines Workspace wird die Projektgrenze aber zu einer Bedingung in der Abfrage statt einer Closure. Das größte Risiko ist, dass Projekt-Keys versehentlich über denselben Codepfad laufen und mehr sehen als heute. Deshalb Projekt-Keys unverändert auf dem heutigen Weg lassen und den Workspace-Server getrennt aufbauen.
- Last: Ein workspace-weites
recalllädt beim ersten Aufruf die Suchindizes aller Projekte in den Speicher (~65 MB und ~170 ms je 10.000 Einträge, siehe 1.1). Heute nur die, die jemand benutzt. SQLite wird nicht stärker belastet, alle Projekte eines Workspace liegen schon in einer Datei. - Schreibbudget: Es zählt je Projekt. Ein durchdrehender Agent mit Workspace-Key könnte in jedem Projekt bis zur Grenze schreiben. Dagegen hilft nur ein zusätzliches Budget je Key oder Workspace, oder kein Schreibrecht für Workspace-Keys.
- Qualität: Agenten schreiben ins falsche Projekt (deshalb keine
Standardangabe für
projectbeiremember), vermischen Wissen verschiedener Projekte, legen Dubletten über Projektgrenzen an. Außerdem sind BM25-Werte getrennter Indizes nicht direkt vergleichbar, das Mischen der Treffer braucht eigene Logik. - Wartung: zwei Anschlussarten statt einer. Mit Keycloak (4.3) wird aus dem Workspace-Key ein Workspace-Recht einer Person, der Rechte-Teil wird dann umgebaut.
Warum aufgeschoben: Keycloak ist absehbar, aber nicht sofort. Mit wenigen Projekten je Team (etwa zwei) ist ein Server je Projekt stabil und die Schwäche nur Komfort. Die Risiken oben wiegen das noch nicht auf.
Auslöser:
- Teams arbeiten regelmäßig mit vier bis fünf oder mehr Projekten zugleich (Werkzeuge vervielfachen sich im Kontext des Agenten)
- Die Verteilung von
read-Keys für gemeinsam genutzte Projekte wird zur spürbaren Last - Die Entscheidung zu 4.3 und 4.8 steht an. Dann zusammen entscheiden, nicht vorher Workspace-Keys bauen und später umbauen.
Wenn es so weit ist, vorsichtig beginnen: Workspace-Keys nur read,
projektübergreifend recall/get/list/related, schreiben weiter über
die Projekt-Server, vorher Speicher und Kaltstart mit echten Projektgrößen
messen. Innerhalb eines Workspace erledigt ein solcher read-Key nebenbei
auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
Workspaces hinweg“ (Variante A oben).
Idee: Mehrere Keys in einem MCP-Block (2026-10-06)
Eine Alternative zu Workspace-Keys, nur als Idee festgehalten, nicht entschieden. Der Client trägt einen Server ein und übergibt alle seine Keys:
"munin": {
"url": "https://munin.firma.de/mcp",
"headers": { "X-Munin-Keys": "wk_aaa,wk_bbb,wk_ccc" }
}
Der Server löst jeden Key einzeln auf dem heutigen Weg auf (Key → Workspace →
Projekt + Rolle). Ist einer ungültig, scheitert die ganze Anfrage
(fail-closed). Die Werkzeuge bekommen einen Parameter project, recall
sucht ohne Angabe in allen freigeschalteten Projekten. Ein einzelner Key in
Authorization: Bearer verhält sich wie heute.
Vorteile gegenüber Workspace-Keys:
- Kein Umbau der Datenbank:
api_keys.project_idbleibtNOT NULL, kein neuer Key-Typ, keine neue Verwaltung. - Rechte bleiben je Key: ein
read-Key bleibtread. Sperren, Protokoll (4.1), Nutzungsübersicht (4.6) und ungenutzte Zugänge (4.7) funktionieren unverändert. - Kein größerer Schaden bei einem verlorenen Key: der Client hält dieselben Keys wie heute, nur in einem statt in N Blöcken.
- Schreibbudget (3.8) bleibt je Projekt.
- Löst das öffentliche Projekt mit: ein
read-Key aus einem fremden Workspace ist in der Liste kein Sonderfall, jeder Key wird für sich geroutet. - 6 Werkzeuge im Kontext des Agenten statt 6 × N.
Risiken für die Richtigkeit der Antworten:
| Risiko | Was passiert | Gegenmittel |
|---|---|---|
| Wissen vermischt | „Wir deployen mit Helm“ aus A wird als allgemeingültig wiedergegeben, obwohl B Ansible nutzt | Jeder Treffer nennt sein Projekt deutlich, Instructions: Aussagen gelten nur für ihr Projekt, bei Widerspruch nachfragen |
| Gleicher Slug in mehreren Projekten | get deployment liest das falsche Projekt; schlimmer: remember überschreibt einen bestehenden Eintrag in B mit Inhalt aus A |
project ist Pflicht für get/remember/forget/related, sobald mehr als ein Key übergeben wird. Fehlt es, Fehlermeldung statt Raten |
| Rangfolge unterschlägt Projekte | BM25-Werte getrennter Indizes sind nicht vergleichbar, ein großes Projekt füllt die Top 10, der Agent schließt „nichts bekannt“ | Treffer je Projekt gruppiert zurückgeben (beste N je Projekt) statt einer gemischten Liste |
| Dubletten | Wissen aus A wird in B neu angelegt und veraltet dort still | Nur über Instructions zu mindern |
Nur der gleiche Slug verändert gespeicherte Daten. Die Pflichtangabe von
project schließt das technisch aus. Die anderen drei betreffen die Qualität
der Antworten und lassen sich nur verringern.
Aufwand (Schätzung, nicht erprobt):
| Umfang | Aufwand | |
|---|---|---|
| Stufe 1 | Nur lesend: recall/get/list/related über mehrere Keys, schreiben weiter über die Projekt-Server |
3–4 Tage |
| Stufe 2 | Zusätzlich remember/forget mit Pflichtangabe project |
+1–2 Tage |
Wenn es so weit ist: mit Stufe 1 beginnen. Dann ist das Überschreiben ausgeschlossen, und an echten Antworten lässt sich sehen, wie stark sich Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
Teil 5 — Oberfläche
| Nutzen | Aufwand | |
|---|---|---|
| ✅ Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) |
✅ Autovervollständigung für [[…]] |
Verweise ohne Abtippen | mittel |
| ✅ Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch |
| ✅ Vergleich zweier Fassungen | setzt 3.6 voraus | mittel |
| ✅ Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering |
✅ Eingebaute Hilfe unter /hilfe |
Onboarding ohne Umweg über das Repository | mittel |
| ✅ Fassung wiederherstellen (5.1) | Fehler eines Agenten mit einem Klick zurücknehmen | gering |
| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering |
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering |
| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering |
| ✅ Ausgewählte Einträge löschen (5.5) | Aufräumen ohne jeden Eintrag einzeln zu öffnen | gering |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist der erste.
5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14)
In der Sammelleiste der Eintragsliste steht neben Anwenden der Knopf
In den Papierkorb (POST /p/{projekt}/entries/bulk-delete, Schreibrecht).
- Selbes Formular wie die Schlagwort-Sammelbearbeitung, anderer Knopf
(
formaction). Er steht hinter Anwenden, weil Enter in einem Textfeld den ersten Knopf auslöst. Rückfrage überdata-confirm(static/confirm.js). store.MoveEntriesToTrash: eine Transaktion, Auswahl über die interne ID; fremde und schon gelöschte IDs werden übersprungen wie beiBulkUpdateTags.- Je Eintrag eine Protokollzeile
entry_delete, damit Nutzungsübersicht und Protokoll jede Löschung einzeln sehen. - Die Weiterleitung behält Schlagwortfilter und Suche (gilt jetzt auch für die Schlagwort-Sammelbearbeitung).
5.1 Fassung wiederherstellen — ✅ umgesetzt
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
dem Vergleich kopieren und von Hand einfügen. Lösung: Knopf Diese Fassung
wiederherstellen in der Versionsansicht (nur mit Schreibrecht). Technisch ist
das ein gewöhnliches PutEntry mit Titel, Inhalt und Schlagworten der alten
Fassung und ExpectedVersion der aktuellen. Der aktuelle Stand wandert dabei
selbst in den Verlauf, es geht nichts verloren. Das Protokoll sollte vermerken,
dass es eine Wiederherstellung war und von welcher Fassung.
✅ Umgesetzt 2026-09-24: POST /p/{project}/e/{slug}/versions/{version}/restore
(handleEntryRestore, internal/web/handlers_entries.go), Knopf auf der
Vergleichsseite und je Zeile im Verlauf, nur mit Schreibrecht. Das Formular
schickt die beim Anzeigen aktuelle Fassung als current_version mit; fehlt sie,
wird abgelehnt (400) statt ohne Konfliktprüfung zu schreiben, bei einem Konflikt
kommt die Verlaufsseite mit Hinweis (409). Die Markierung kuratiert bleibt,
wie sie ist — sie beschreibt den Eintrag, nicht einen Textstand. Im Protokoll
steht wiederhergestellt: Fassung n (store.EntryRestoreDetail); für die
Zählung in 4.6 zählt das als Aktualisierung (detail LIKE 'wiederhergestellt%').
5.2 Echte Server-Adresse auf der Zugangsseite — ✅ umgesetzt
keys.html zeigt in den Konfigurationsblöcken noch https://DEIN-SERVER/mcp.
Die Hilfe setzt die Adresse schon ein (serverURL in
internal/web/handlers_hilfe.go). Dieselbe Funktion für keyListData
verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
✅ Umgesetzt 2026-09-24: keyListData.ServerURL, gesetzt in
renderKeyList; keys.html nutzt es in allen drei Konfigurationsblöcken.
5.3 Mehr Markdown in Einträgen — ✅ umgesetzt
entry.RenderHTML nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in
Einträgen deshalb als Text, in der Hilfe (internal/hilfe) dagegen als
Tabelle. Lösung: extension.GFM einschalten (Tabellen, Durchstreichen,
Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die
Sicherheitsannahme in RenderHTML ändert sich nicht. Danach den Hinweis auf
der Hilfeseite Weboberfläche entfernen und Hilfe und Einträge über denselben
Renderer laufen lassen.
✅ Umgesetzt 2026-09-24: Neues Paket internal/markdown — ein
goldmark-Renderer mit extension.GFM für beide. entry.RenderHTML und
hilfe.Render rufen nur noch dort hinein. Einziger Unterschied:
RenderMitAnkern (Hilfe) vergibt Überschrift-ids, Render (Einträge) nicht —
Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche"
soll nicht mit einer id der Seite selbst zusammenstoßen können.
markdown_test.go prüft, dass auch mit GFM weder Roh-HTML noch
javascript:-Links durchkommen. Die Hilfe beschreibt jetzt Tabellen,
Checklisten, Durchstreichen und automatische Links.
5.4 Hilfe an Ort und Stelle verlinken — ✅ umgesetzt
Kleine „?“-Links von einer Seite zur passenden Hilfe, zum Beispiel Zugänge →
/hilfe/zugaenge, Editor → Markdown-Abschnitt, leere Eintragsliste →
Agent anbinden. Dazu Überschrift-Anker in den Hilfeseiten
(parser.WithAutoHeadingID), damit Links auf einen Abschnitt zeigen können.
✅ Umgesetzt 2026-09-24: Partial hilfelink in partials.html, ein
kleines „?" neben Überschriften und dem Inhaltsfeld des Editors: Einträge,
Editor (Markdown-Abschnitt), Verlauf, Vergleich, Zugänge, Protokoll (je nach
Ansicht Projekt- oder Admin-Hilfe), Workspaces, Projekte. Die leere
Eintragsliste verlinkt auf Agent anbinden und Mit Agenten arbeiten. Die
Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein
ungespeicherter Text verloren geht.
Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute
einfach fallen („zugnge-verwalten") —, sondern ankerIDs in
internal/markdown über store.Slugify („zugaenge-verwalten"), dieselbe
Regel wie für Eintragsbezeichner. TestHilfelinksZeigenAufVorhandeneAbschnitte
liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und
Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so
im Test, welcher Link dadurch ins Leere zeigen würde.
✅ Sammelbearbeitung umgesetzt: entries.html zeigt bei Schreibrecht ein
Auswahlkästchen je Eintrag plus eine Sammelleiste (Schlagworte hinzufügen/
entfernen, kommagetrennt). Store.BulkUpdateTags (internal/store/entries.go)
prüft je ID, dass sie wirklich zum aufrufenden Projekt gehört — eine
untergeschobene ID aus einem fremden Projekt wird stillschweigend übersprungen,
nicht verändert. Nur tatsächlich geänderte Einträge zählen als Treffer und
zählen ihre version hoch; der Suchindex wird für sie nachgezogen. Kein
JavaScript nötig — ein einziges Formular um die ganze Liste.
✅ Fassungsvergleich umgesetzt: Baut auf 3.6 auf. /p/{project}/e/{slug}/versions
zeigt den Verlauf, /versions/{version} vergleicht eine abgelöste Fassung mit
ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine mehr
gibt, der aktuell gültigen. Der Zeilenvergleich selbst
(internal/entry/diff.go, DiffLines) ist eine klassische
LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für
außergewöhnlich lange Einträge (grobDiff oberhalb von vier Millionen
Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern.
✅ Vorschau beim Bearbeiten umgesetzt: Das erste nennenswerte
JavaScript im Projekt (static/preview.js, ~90 Zeilen) neben confirm.js.
Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld in edit.html; ein Klick
auf "Vorschau" schickt den aktuellen Textarea-Inhalt per fetch an
POST /p/{project}/preview (handleEntryPreview,
internal/web/handlers_entries.go) und zeigt das Ergebnis an. Bewusst kein
Markdown-Parsing im Browser: Der Endpunkt ruft denselben entry.RenderHTML
auf wie die Detailansicht, Vorschau und späteres Ergebnis können also nicht
auseinanderlaufen — und es ist keine externe JS-Bibliothek nötig, die an der
Content-Security-Policy (script-src 'self') vorbeigemusst hätte. Ohne
JavaScript bleibt einfach der Reiter "Schreiben" aktiv, das Formular
funktioniert unverändert. Kein Live-Rendern beim Tippen, nur auf Klick —
hält die Zahl der Aufrufe klein.
✅ Autovervollständigung umgesetzt: static/autocomplete.js erkennt
beim Tippen ein noch nicht geschlossenes [[, fragt
GET /p/{project}/entries/suggest?q=… (handleEntrySuggest,
internal/web/handlers_entries.go) und zeigt die Treffer als Liste unter dem
Feld — bewusst nicht an der Schreibmarke ausgerichtet, das bräuchte einen
Spiegel-Trick zur Pixel-Positionierung im Textfeld, den die Liste unter dem
Feld erspart. Tastatur (Pfeiltasten, Enter/Tab, Escape) und Maus bedienbar.
Die Suche selbst dupliziert keine Logik: Mit Suchbegriff läuft dieselbe
BM25-Volltextsuche wie bei recall (MCP) und der Projektsuche
(search.Registry), ohne Suchbegriff kommen die zuletzt geänderten
Einträge. Ausgewählt wird der Bezeichner (slug), nicht der Titel — das ist,
was [[…]] erwartet.
✅ Graph-Ansicht umgesetzt: /p/{project}/graph (Seite) und
/p/{project}/graph/data (JSON, handleEntryGraphData). Datenseitig neu ist
nur Store.LinkGraph (internal/store/entries.go) — eine Abfrage über den
bereits vorhandenen, indizierten entry_links. Verweise auf einen
Bezeichner ohne Eintrag erzeugen einen eigenen, gestrichelten Knoten
(Exists: false) statt stillschweigend zu verschwinden — sichtbar macht,
wohin schon verwiesen wird, was aber noch fehlt.
Begrenzung wie in der Aufwands-/Risikoabschätzung besprochen:
graphNodeLimit = 150 — bei mehr Einträgen bleiben die am stärksten
verknüpften übrig (nach Grad sortiert), die Antwort trägt truncated: true
und die Gesamtzahl, die Seite zeigt einen Hinweis. Das hält sowohl die
Kräftesimulation (static/graph.js, O(Knoten²) je Durchlauf) als auch die
SVG-Darstellung in einem Bereich, der im Browser flüssig bleibt.
Kein externes Diagramm-Werkzeug: Die Kräftesimulation (Abstoßung zwischen
allen Knotenpaaren, Anziehung entlang der Kanten, schwache Zentrierung) ist
selbst geschrieben, läuft einmal beim Laden durch und rendert dann reines
SVG. Texte werden ausschließlich über textContent gesetzt, nie
innerHTML — Eintragstitel kommen von Menschen und Agenten und sind damit
nicht vertrauenswürdig. Zoom (Mausrad) und Pan (Ziehen) über eine simple
Transform-Gruppe, keine Abhängigkeit auf eine Bibliothek.
Empfohlene Reihenfolge
Wenn es demnächst weitergehen soll, in dieser Folge — jeweils kleiner Aufwand, sichtbarer Gewinn:
1. Zugriffsprotokoll (4.1) · 2. Umlaute falten (2.1) ·
3. Automatisierte Tests (4.2) — erledigt.
4. Obergrenze je Projekt (3.8) — erledigt.
Als Nächstes:
Stand der Priorisierung: 2026-09-24.
Vor dem zweiten Workspace
Offene Reste aus 4.4 (WP12 und WP13): Protokoll aufräumen und Export für alle Workspaces.— erledigt 2026-09-24, zusammen mit der Unterscheidung angelegt/aktualisiert im Protokoll (Voraussetzung für 4.6).
Klein, sofort spürbar für die Teams
Fassung wiederherstellen (5.1) und Server-Adresse auf der Zugangsseite (5.2). Zusammen ein kleines Paket.— erledigt 2026-09-24.Mehr Markdown (5.3) und Hilfe an Ort und Stelle (5.4).— erledigt 2026-09-24.
Mittel, wichtig bei vielen Teams
Papierkorb (3.9): schließt den einzigen Schaden, der sich heute nicht rückgängig machen lässt.— erledigt 2026-09-24.Nutzungsübersicht (4.6): zeigt, ob die Teams Munin wirklich benutzen, und liefert die Zahlen für Punkt 7.— erledigt 2026-09-24.Ungenutzte Zugänge (4.7), zunächst nur Stufe 1.— erledigt 2026-09-25. Stufe 2 (Ablaufdatum) erst, wenn Stufe 1 nicht reicht.
Bewusst entscheiden, nicht nebenbei
instructionsschärfen (3.1) — braucht ein paar Wochen echter Nutzung. Das Zugriffsprotokoll, 4.6 und seit 2026-10-14 das Suchprotokoll liefern die Daten dafür: Wie vieleremember-Aufrufe legen neu an, wie viele Suchen bleiben ohne Treffer?Kandidaten bei Dubletten (3.3)— erledigt 2026-10-14, zusammen mit dem Schutz kuratierter Einträge (3.3a). Bezeichner vorschlagen (3.4) bleibt offen; es ändert den MCP-Vertrag.Admin je Workspace (4.8)— erledigt 2026-09-30, ohne Keycloak (siehe die Entscheidung dort). Keycloak (4.3) bleibt offen, bis es gebraucht wird.
Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die
Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es
sich in der Spalte „Schreibbudget" unter /admin/projects. Wenn dort nie etwas
knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder
das Limit zu eng oder ein Agent tut etwas Unsinniges.
Zusätzlich vorgezogen und erledigt 2026-09-25: Verwandte Einträge (3.7), Aktualität, kleiner Schritt (3.5) und Importer (3.2).
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
Was ausdrücklich nicht auf dieser Liste steht: FTS5, getrennte Datenbanken, semantische Suche, Zugriff über ein Projekt hinaus (4.9). Alle vier sind gute Ideen für den Fall, dass ihre Auslöser eintreten — und vier gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt.