package mcpserver import ( "context" "errors" "fmt" "log" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "munin/internal/auth" "munin/internal/entry" "munin/internal/store" "munin/internal/web" "munin/internal/workspace" ) // workspaceFrom holt den Workspace (Datenbank + Suchindex) des aktuellen // Aufrufs aus dem Context. // // web.APIAuth hat ihn dort abgelegt, nachdem es den Bearer-Key aufgelöst hat // (siehe internal/web/middleware.go). Fehlt er, ist der Server falsch // verdrahtet — MCP-Aufrufe laufen nie ohne durchlaufenes APIAuth hier an. func workspaceFrom(ctx context.Context) (*workspace.Workspace, error) { ws, ok := web.WorkspaceFromContext(ctx) if !ok { return nil, errors.New("kein Workspace aufgelöst") } return ws, nil } // Grenzen für die Werkzeuge. // // Die Obergrenzen schützen nicht den Server, sondern den Client: Eine Antwort mit // 500 Einträgen sprengt das Kontextfenster des Modells und macht die Antwort // schlechter, nicht besser. const ( recallDefaultLimit = 10 recallMaxLimit = 50 recallMaxSynonyms = 10 // mehr bringt nichts, verwässert aber die Bewertung listDefaultLimit = 50 listMaxLimit = 200 relatedDefaultLimit = 20 relatedMaxLimit = 50 relatedDefaultDistance = 2 // relatedMaxDistance ist bewusst klein. Schon in der dritten Reihe hängt // in einem gut verknüpften Projekt fast alles mit allem zusammen, und die // Antwort sagt nichts mehr über das Thema aus. relatedMaxDistance = 2 ) // staleHint steht bei einem veralteten Eintrag in der Antwort von "get". Er // sagt dem Modell, was es tun soll, nicht nur, was der Fall ist. const staleHint = "Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt, " + "und bei der Antwort erwähnen, dass der Stand alt ist. Ist bekannt, was sich geändert hat: mit \"remember\" aktualisieren." // buildServer erstellt den MCP-Server eines Projekts. // // Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht // erst auf ein anderes zugreifen. func (h *Handler) buildServer(project store.Project) *mcp.Server { server := mcp.NewServer( &mcp.Implementation{Name: "munin", Version: "2.0.0"}, &mcp.ServerOptions{Instructions: instructions}, ) h.addRecall(server, project) h.addGet(server, project) h.addList(server, project) h.addRemember(server, project) h.addForget(server, project) h.addRelated(server, project) return server } // requireWrite prüft, ob dieser Aufruf schreiben darf — und verbucht ihn im // Schreibbudget des Projekts. // // Zwei Fragen, eine Stelle: Die Rolle sagt, ob der Zugang GRUNDSÄTZLICH schreiben // darf, das Budget, ob das Projekt gerade noch DARF. Beide Prüfungen gehören // zusammen, weil sie sonst an jedem schreibenden Werkzeug einzeln stehen müssten // und irgendwann eine davon vergessen wird. // // Die Rolle steckt im Context, den web.APIAuth gefüllt hat. Ein Werkzeug bei // fehlender Berechtigung ganz auszublenden wäre möglich, wäre aber verwirrender: // Das Modell sähe je nach Key unterschiedliche Werkzeuge und könnte nicht // erklären, warum etwas nicht geht. func (h *Handler) requireWrite(ctx context.Context) error { sess, ok := web.SessionFromContext(ctx) if !ok { return errors.New("kein gültiger Zugang") } if !sess.Role.CanWrite() { return fmt.Errorf("dieser Zugang hat nur Leserecht (Rolle %q) — zum Schreiben wird %q oder %q benötigt", sess.Role, auth.RoleWrite, auth.RoleAdmin) } if h.budget == nil { return nil } // Die Obergrenze kommt aus dem Projekt der Sitzung, nicht aus einer eigenen // Abfrage: web.APIAuth hat die Projektzeile für DIESEN Request gerade eben // frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der // Schreibpfad kostet keine zusätzliche Abfrage. erlaubt, retryAfter, ueberschreitung := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly) if erlaubt { return nil } // Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine // Schleife im Agenten das Protokoll voll, statt die Einträge. if ueberschreitung == 1 { log.Printf("Schreibbudget erschöpft: Projekt %q (%s), Zugang %q — weitere Schreibversuche werden bis zum Fensterende abgelehnt", sess.Project.Slug, sess.Project.ID, sess.Key.Name) } // Die Meldung geht an ein Sprachmodell, nicht an einen Menschen. Deshalb // steht darin ausdrücklich, was es TUN soll: warten, nicht neu versuchen und // nicht auf ein anderes Werkzeug ausweichen. return fmt.Errorf( "Schreibbudget des Projekts erschöpft. In %d Minuten ist wieder Kontingent frei. "+ "NICHT sofort erneut versuchen und nicht auf andere Werkzeuge ausweichen — "+ "stattdessen die Aufgabe abbrechen und melden, dass das Schreiblimit erreicht ist", int(retryAfter.Minutes())+1) } // actorName gibt den Namen des benutzten Zugangs zurück — für Herkunft und Protokoll. func actorName(ctx context.Context) string { if sess, ok := web.SessionFromContext(ctx); ok { return sess.Key.Name } return "unbekannt" } // ── recall ─────────────────────────────────────────────────────────────────── func (h *Handler) addRecall(server *mcp.Server, project store.Project) { mcp.AddTool(server, &mcp.Tool{ Name: "recall", Description: "Durchsucht das Gedächtnis dieses Projekts. IMMER zuerst aufrufen — " + "sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden. " + "Die Suche vergleicht Wörter, keine Bedeutungen: unter \"synonyms\" weitere Begriffe " + "für dasselbe Thema mitgeben (Synonyme, Übersetzung, Abkürzungen).", }, func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) { ws, err := workspaceFrom(ctx) if err != nil { return nil, recallOutput{}, err } if strings.TrimSpace(in.Query) == "" { return nil, recallOutput{}, errors.New("query darf nicht leer sein") } limit := in.Limit if limit <= 0 { limit = recallDefaultLimit } if limit > recallMaxLimit { limit = recallMaxLimit } idx, err := ws.Search.For(ctx, project.ID) if err != nil { return nil, recallOutput{}, err } // Etwas großzügiger suchen als am Ende ausgegeben wird: Die // Schlagwortfilterung unten entfernt womöglich noch Treffer. synonyme := begrenzeSynonyme(in.Synonyms) treffer := idx.Search(mitSynonymen(in.Query, synonyme), limit*3) gesucht := store.NormalizeTags(in.Tags) jetzt := time.Now() veraltet := 0 ergebnisse := make([]recallMatch, 0, limit) for _, t := range treffer { if len(ergebnisse) >= limit { break } e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) if errors.Is(err, store.ErrNotFound) { continue // Index kennt einen Eintrag, den es nicht mehr gibt } if err != nil { return nil, recallOutput{}, err } if !hatAlleTags(e.Tags, gesucht) { continue } alt := e.Stale(jetzt) if alt { veraltet++ } ergebnisse = append(ergebnisse, recallMatch{ Slug: e.Slug, Title: e.Title, Snippet: entry.Snippet(e.Content, mitSynonymen(in.Query, synonyme)), Tags: e.Tags, Source: e.Source, Curated: e.Curated, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), Stale: alt, }) } // Ins Suchprotokoll, für die Nutzungsübersicht. WithoutCancel: Bricht // der Client jetzt ab, ist die Suche trotzdem geschehen. sess, _ := web.SessionFromContext(ctx) ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Query: in.Query, Synonyms: synonyme, Results: len(ergebnisse), }) out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)} if len(ergebnisse) == 0 { out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " + "Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " + "Erst wenn es wirklich nichts gibt und etwas Neues festgehalten werden soll, " + "mit \"remember\" einen Eintrag anlegen." } else { out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen." // Veraltete Treffer bleiben in der Liste und rutschen auch // nicht nach hinten: Ein alter Eintrag kann der einzige zum // Thema sein. Der Hinweis sorgt dafür, dass das Modell das // Feld "stale" nicht übersieht. if veraltet > 0 { out.Hint += fmt.Sprintf(" %d der Treffer sind seit über einem Jahr unverändert (\"stale\": true) "+ "— vor Verwendung prüfen, ob sie noch stimmen.", veraltet) } } return nil, out, nil }, ) } // mitSynonymen hängt die Synonyme an die Anfrage an, sodass sie in derselben // Suche mitlaufen. // // # Warum so einfach? // // BM25 zählt jedes Suchwort für sich und addiert. Ein Eintrag, der das Thema // unter einem anderen Namen führt („Release" statt „Deployment"), wird damit // gefunden; einer, der beide Namen enthält, steht weiter oben. Genau das ist // gewollt — eine eigene Gewichtung für Synonyme bräuchte es dafür nicht. // // Die Synonyme sollten vorher durch begrenzeSynonyme gegangen sein. func mitSynonymen(query string, synonyme []string) string { if len(synonyme) == 0 { return query } return query + " " + strings.Join(synonyme, " ") } // begrenzeSynonyme entfernt leere Angaben und kappt die Liste bei // recallMaxSynonyms. // // Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe // mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon. func begrenzeSynonyme(synonyme []string) []string { var sauber []string for _, s := range synonyme { if s = strings.TrimSpace(s); s != "" { sauber = append(sauber, s) } if len(sauber) == recallMaxSynonyms { break } } return sauber } // hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind. func hatAlleTags(vorhanden, gesucht []string) bool { if len(gesucht) == 0 { return true } menge := make(map[string]bool, len(vorhanden)) for _, t := range vorhanden { menge[t] = true } for _, g := range gesucht { if !menge[g] { return false } } return true } // ── get ────────────────────────────────────────────────────────────────────── func (h *Handler) addGet(server *mcp.Server, project store.Project) { mcp.AddTool(server, &mcp.Tool{ Name: "get", Description: "Lädt einen Eintrag vollständig, samt seiner Verweise. " + "Die gelieferte \"version\" bei einem späteren \"remember\" mitgeben.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in getInput) (*mcp.CallToolResult, getOutput, error) { ws, err := workspaceFrom(ctx) if err != nil { return nil, getOutput{}, err } e, err := ws.Store.EntryBySlug(ctx, project.ID, in.Slug) if errors.Is(err, store.ErrNotFound) { return nil, getOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug) } if err != nil { return nil, getOutput{}, err } rueck, err := ws.Store.Backlinks(ctx, project.ID, in.Slug) if err != nil { return nil, getOutput{}, err } linkedBy := make([]string, 0, len(rueck)) for _, b := range rueck { linkedBy = append(linkedBy, b.Slug) } out := getOutput{ Slug: e.Slug, Title: e.Title, Content: e.Content, Tags: e.Tags, Source: e.Source, Author: e.Author, Curated: e.Curated, Version: e.Version, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), LinksTo: store.ExtractLinks(e.Content), LinkedBy: linkedBy, } if e.Stale(time.Now()) { out.Stale = true out.Hint = staleHint } return nil, out, nil }, ) } // ── list ───────────────────────────────────────────────────────────────────── func (h *Handler) addList(server *mcp.Server, project store.Project) { mcp.AddTool(server, &mcp.Tool{ Name: "list", Description: "Gibt einen Überblick über das Gedächtnis: zuletzt geänderte Einträge " + "und alle vergebenen Schlagworte. Nützlich, wenn unklar ist, wonach zu suchen wäre.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) { ws, err := workspaceFrom(ctx) if err != nil { return nil, listOutput{}, err } limit := in.Limit if limit <= 0 { limit = listDefaultLimit } if limit > listMaxLimit { limit = listMaxLimit } eintraege, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{ Tag: in.Tag, Curated: in.Curated, Limit: limit, }) if err != nil { return nil, listOutput{}, err } gesamt, err := ws.Store.CountEntries(ctx, project.ID) if err != nil { return nil, listOutput{}, err } tags, err := ws.Store.ListTags(ctx, project.ID) if err != nil { return nil, listOutput{}, err } jetzt := time.Now() items := make([]listItem, 0, len(eintraege)) for _, e := range eintraege { items = append(items, listItem{ Slug: e.Slug, Title: e.Title, Tags: e.Tags, Source: e.Source, Curated: e.Curated, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), Stale: e.Stale(jetzt), }) } tagNamen := make([]string, 0, len(tags)) for _, t := range tags { tagNamen = append(tagNamen, t.Tag) } return nil, listOutput{Entries: items, Total: gesamt, Tags: tagNamen}, nil }, ) } // ── remember ───────────────────────────────────────────────────────────────── func (h *Handler) addRemember(server *mcp.Server, project store.Project) { mcp.AddTool(server, &mcp.Tool{ Name: "remember", Description: "Legt einen Eintrag an oder aktualisiert ihn. Der slug entscheidet: " + "Ein vorhandener Eintrag wird überschrieben, kein neuer danebengestellt. " + "VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden. " + "Ähnelt ein NEUER Eintrag einem vorhandenen, wird nichts geschrieben: Die Antwort nennt " + "\"candidates\" — einen davon fortschreiben oder mit \"force\": true doch neu anlegen. " + "Kuratierte Einträge (von Menschen geprüft) ändert es nur mit \"override_curated\": true und \"reason\".", }, func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) { ws, err := workspaceFrom(ctx) if err != nil { return nil, rememberOutput{}, err } if err := h.requireWrite(ctx); err != nil { return nil, rememberOutput{}, err } if strings.TrimSpace(in.Content) == "" { return nil, rememberOutput{}, errors.New("content darf nicht leer sein") } if err := begruendungPruefen(in); err != nil { return nil, rememberOutput{}, err } slug := strings.TrimSpace(in.Slug) if slug == "" { // Kein Bezeichner mitgegeben — aus dem Titel ableiten, statt den // Aufruf abzulehnen. Das Modell soll nicht an Formalien scheitern. slug = store.Slugify(in.Title) } if slug == "" { return nil, rememberOutput{}, errors.New("slug fehlt und ließ sich nicht aus dem Titel ableiten") } titel := strings.TrimSpace(in.Title) if titel == "" { titel = entry.TitleFromContent(in.Content) } // Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner? // Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe // dubletten.go). Kein Fehler: Das Modell soll die Kandidaten als // Daten lesen und entscheiden, nicht einen Fehlschlag behandeln. if !in.Force { kandidaten, err := pruefeDubletten(ctx, ws, project, slug, titel, in.Content) if err != nil { return nil, rememberOutput{}, err } if len(kandidaten) > 0 { return nil, rememberOutput{ Slug: slug, JudgmentRequired: true, Candidates: kandidaten, Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " + "Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " + "mit DESSEN slug erneut \"remember\" aufrufen. Ist es wirklich ein anderes Thema, " + "denselben Aufruf mit \"force\": true wiederholen.", }, nil } } // Kuratierter Eintrag? Dann nur mit override_curated (siehe // kuratiert.go). Wie bei den Dubletten kein Fehler, sondern eine // Meldung, die das Modell liest und auf die es entscheidet. warKuratiert, err := istKuratiert(ctx, ws, project, slug) if err != nil { return nil, rememberOutput{}, err } if warKuratiert && !in.OverrideCurated { return nil, rememberOutput{ Slug: slug, JudgmentRequired: true, Message: kuratiertHinweis, }, nil } geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{ ProjectID: project.ID, Slug: slug, Title: titel, Content: in.Content, Tags: in.Tags, Source: store.SourceAgent, Author: actorName(ctx), ExpectedVersion: in.ExpectedVersion, }) if errors.Is(err, store.ErrConflict) { // Die Meldung des Stores nennt bereits die Fassungsnummern — // hier kommt nur die Handlungsanweisung fürs Modell dazu. return nil, rememberOutput{}, fmt.Errorf( "%w. Mit \"get\" den aktuellen Stand laden, die eigenen Änderungen einarbeiten und erneut schreiben", err) } if err != nil { return nil, rememberOutput{}, err } ws.Search.Refresh(ctx, project.ID, geschrieben.Slug) // Hat der Agent einen kuratierten Eintrag geändert, steht das samt // Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste // „bitte prüfen" (store.CuratedOverrides). detail := store.EntryWriteDetail(geschrieben) if warKuratiert { detail = store.CuratedOverrideDetail(in.Reason) } sess, _ := web.SessionFromContext(ctx) ws.Store.LogAudit(ctx, store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP, Detail: detail, }) // "angelegt" oder "aktualisiert"? Für das Modell ist dieser // Unterschied wichtig: Es soll merken, wenn es unabsichtlich etwas // überschrieben hat. Maßgeblich ist die Fassung nach dem Schreiben // (PutEntry vergibt beim Anlegen immer 1) — eine Nachfrage vorher // könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie // sähe einen Eintrag im Papierkorb nicht. istNeu := geschrieben.Version == 1 meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version) switch { case warKuratiert: meldung = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+ "ist aufgehoben, und ein Mensch prüft die Änderung. Der geprüfte Stand steht im Verlauf.", slug, geschrieben.Version) case istNeu: meldung = fmt.Sprintf("Eintrag %q angelegt.", slug) case geschrieben.RestoredFromTrash: // Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell // konnte ihn vorher mit recall/get nicht sehen — es soll aber // wissen, dass es gerade etwas Gelöschtes wiederbelebt hat. meldung = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+ "und mit deinem Inhalt überschrieben (Fassung %d); der gelöschte Stand steht im Verlauf. "+ "War das nicht beabsichtigt, den Menschen darauf hinweisen.", slug, geschrieben.Version) ws.Store.LogAudit(ctx, store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceMCP, Detail: "durch Schreiben unter demselben Bezeichner", }) } return nil, rememberOutput{ Slug: slug, Version: geschrieben.Version, Created: istNeu, Message: meldung, }, nil }, ) } // ── forget ─────────────────────────────────────────────────────────────────── func (h *Handler) addForget(server *mcp.Server, project store.Project) { mcp.AddTool(server, &mcp.Tool{ Name: "forget", // Die Beschreibung sagt ehrlich, dass Löschen nicht endgültig ist — // verschweigt aber nicht, dass es trotzdem Arbeit für Menschen // erzeugt. Ein Agent soll nicht leichtfertiger löschen, nur weil es // einen Papierkorb gibt. Wiederherstellen kann er selbst nicht: Das // ist Absicht (siehe AUSBAUSTUFEN.md 3.9). Description: fmt.Sprintf("Löscht einen Eintrag: Er verschwindet sofort aus allen Antworten und liegt "+ "%d Tage im Papierkorb, wo nur ein Mensch ihn zurückholen kann. Nur verwenden, wenn Wissen "+ "nachweislich falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.", store.TrashRetentionDays), }, func(ctx context.Context, _ *mcp.CallToolRequest, in forgetInput) (*mcp.CallToolResult, forgetOutput, error) { ws, err := workspaceFrom(ctx) if err != nil { return nil, forgetOutput{}, err } if err := h.requireWrite(ctx); err != nil { return nil, forgetOutput{}, err } err = ws.Store.MoveEntryToTrash(ctx, project.ID, in.Slug, actorName(ctx)) if errors.Is(err, store.ErrNotFound) { return nil, forgetOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q", in.Slug) } if err != nil { return nil, forgetOutput{}, err } ws.Search.Refresh(ctx, project.ID, in.Slug) sess, _ := web.SessionFromContext(ctx) ws.Store.LogAudit(ctx, store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), Action: store.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP, Detail: "in den Papierkorb", }) return nil, forgetOutput{Message: fmt.Sprintf( "Eintrag %q gelöscht. Er liegt %d Tage im Papierkorb; ein Mensch kann ihn dort wiederherstellen.", in.Slug, store.TrashRetentionDays)}, nil }, ) } // ── related ────────────────────────────────────────────────────────────────── func (h *Handler) addRelated(server *mcp.Server, project store.Project) { mcp.AddTool(server, &mcp.Tool{ Name: "related", Description: "Liefert die Einträge, die über [[Verweise]] mit einem Eintrag verknüpft sind — " + "direkt und (Standard) auch über einen Zwischeneintrag. Findet Zusammenhänge, " + "die \"recall\" nicht findet, weil kein Suchwort passt. Nützlich, nachdem \"get\" " + "einen passenden Eintrag geliefert hat und mehr Kontext gebraucht wird.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in relatedInput) (*mcp.CallToolResult, relatedOutput, error) { ws, err := workspaceFrom(ctx) if err != nil { return nil, relatedOutput{}, err } abstand := in.Distance if abstand <= 0 { abstand = relatedDefaultDistance } if abstand > relatedMaxDistance { abstand = relatedMaxDistance } limit := in.Limit if limit <= 0 { limit = relatedDefaultLimit } if limit > relatedMaxLimit { limit = relatedMaxLimit } verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, limit) if errors.Is(err, store.ErrNotFound) { return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug) } if err != nil { return nil, relatedOutput{}, err } jetzt := time.Now() items := make([]relatedItem, 0, len(verwandte)) for _, v := range verwandte { items = append(items, relatedItem{ Slug: v.Slug, Title: v.Title, Distance: v.Distance, Via: v.Via, Relation: v.Relation, Updated: v.UpdatedAt.Format("2006-01-02 15:04"), Stale: store.StaleSince(v.UpdatedAt, jetzt), }) } out := relatedOutput{Results: items, Total: len(items)} if len(items) == 0 { out.Hint = "Dieser Eintrag ist mit keinem anderen verknüpft. Mit \"recall\" nach verwandten " + "Themen suchen. Findet sich etwas, lohnt es sich, die Einträge mit [[bezeichner]] zu verknüpfen." } else { out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen." } return nil, out, nil }, ) }