2 Commits

Author SHA1 Message Date
89312a9ce4 feat(web): redesign Knowledge as an editable wiki
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Replaces the read-only stats dashboard with a three-pane wiki: a
navigator tree (group by folder/type/tag/entity), a reader/editor with
bare-slug auto-linking and revision history + diff, and a context rail
for backlinks and related notes. Adds a Cleanup mode for the drift
tools (duplicates, tag manager, orphans, trash) and a Cmd+K quick-open.

Also:
- Adds a real landing view (hero count, KPI row, Nomos-share meter,
  recently-updated, busiest tags) in place of the old "Select a note"
  empty state, and extends the design pass across the tree/reader/rail
  (kind icons instead of repeated text badges, accent-bar selection,
  constrained prose measure).
- Guards every note-selection path behind a confirm when there's an
  unsaved edit in progress, so switching notes can no longer silently
  discard a draft.
- Extracts the markdown-rendering CSS duplicated across ChatThread,
  EntityDetailContent, and the new WikiReader into a shared
  .markdown-body class in app.css, with ChatThread keeping only its
  decorative deltas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 22:50:54 +02:00
ce0e4142ff feat(api): add knowledge base write path, revision history, and drift tooling
The Knowledge page was read-only from the HTTP API — the only writer was
the agent's MCP upsert_knowledge tool. Adds create/update/soft-delete/
restore/trash endpoints, a DB-trigger-backed revision history (catches
both the web UI and the MCP tool), and maintenance endpoints: duplicate
detection (pg_trgm + complete-linkage clustering), tag rename/normalize,
orphan detection, and merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 22:50:29 +02:00
19 changed files with 3997 additions and 272 deletions

View File

@@ -50,6 +50,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ($1 = '' OR ke.source = $1)
AND ke.deleted_at IS NULL
ORDER BY ke.updated_at DESC
LIMIT $2`, source, limit)
if err != nil {
@@ -82,6 +83,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
WHERE ke.deleted_at IS NULL
GROUP BY e.type`)
if err == nil {
defer srows.Close()
@@ -128,15 +130,19 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
return
}
var title, content, source string
var title, content, source, editedBy string
var tags []string
var updatedAt string
var revisions int
err = s.pool.QueryRow(ctx, `
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
SELECT ke.title, ke.content, COALESCE(ke.source,''), COALESCE(ke.edited_by,''),
ke.tags, ke.updated_at::text,
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
Scan(&title, &content, &source, &tags, &updatedAt)
WHERE (e.slug = $1 OR e.id::text = $1)
AND ke.deleted_at IS NULL`, idOrSlug).
Scan(&title, &content, &source, &editedBy, &tags, &updatedAt, &revisions)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
return
@@ -150,8 +156,10 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
"title": title,
"content": content,
"source": source,
"edited_by": editedBy,
"tags": tags,
"updated_at": updatedAt,
"revisions": revisions,
})
}
@@ -169,6 +177,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type
WHERE ke.search @@ plainto_tsquery('english', $1)
AND ke.deleted_at IS NULL
ORDER BY rank DESC
LIMIT $2`,
q, limit)
@@ -232,6 +241,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
WHERE target.slug = $1
AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
AND ke.deleted_at IS NULL
UNION
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke
@@ -242,6 +252,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL
AND r.type = 'procedure-for'
AND ke.deleted_at IS NULL
ORDER BY 2`,
entitySlug)
if err != nil {

View File

@@ -0,0 +1,544 @@
package httpapi
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
)
// Drift tooling for the knowledge base — the maintenance half of the wiki.
//
// These endpoints exist because the knowledge base measurably rots on its
// own. Two failure modes are already present in live data:
//
// - **Duplicate pileup.** upsert_knowledge keys on exact title, so a note
// titled "rclone backup live inspection — 2026-07-15 10:08 UTC" and one
// titled "... 11:18 UTC" are different notes. A single day of agent
// activity produced eight near-identical investigations that should have
// been one living page. Nothing surfaced that, so it kept happening.
// - **Tag drift.** `oom` and `OOM` were separate tags; so were `422` and
// `proton-422`. Each split halves the usefulness of tag navigation, and
// neither is visible from any single note.
//
// normalizeTags (knowledge_write.go) stops new casing splits at the door;
// these endpoints clean up what's already there and make the rot visible.
// serveKnowledgeTags returns the tag index: every tag with its usage count,
// plus the distinct casings actually stored. `variants` is the interesting
// column — it's how the operator discovers that `oom` and `OOM` are the same
// idea filed twice, which no individual note reveals.
func (s *Server) serveKnowledgeTags(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
rows, err := s.pool.Query(ctx, `
SELECT lower(tag) AS norm,
count(*) AS uses,
array_agg(DISTINCT tag ORDER BY tag) AS variants
FROM knowledge_entities ke, unnest(ke.tags) AS tag
WHERE ke.deleted_at IS NULL
GROUP BY lower(tag)
ORDER BY uses DESC, norm`)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return
}
defer rows.Close()
type tagRow struct {
Tag string `json:"tag"`
Uses int `json:"uses"`
Variants []string `json:"variants"`
// True when the same tag is stored under more than one casing —
// the UI badges these as needing a normalize.
Split bool `json:"split"`
}
items := []tagRow{}
for rows.Next() {
var t tagRow
if err := rows.Scan(&t.Tag, &t.Uses, &t.Variants); err != nil {
slog.Error("httpapi: knowledge/tags row scan failed", "error", err)
continue
}
t.Split = len(t.Variants) > 1
items = append(items, t)
}
writeJSON(w, map[string]any{"items": items})
}
// serveRenameKnowledgeTag rewrites one or more tags to a single target across
// every live note — the merge/rename/normalize action behind the tag manager.
// Passing several `from` values into one `to` is the merge case
// (`{"from":["422","proton-422"],"to":"proton-422"}`); passing one is a plain
// rename; passing the mixed-case variants is the normalize case.
func (s *Server) serveRenameKnowledgeTag(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
var body struct {
From []string `json:"from"`
To string `json:"to"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
return
}
to := strings.ToLower(strings.TrimSpace(body.To))
from := []string{}
for _, f := range body.From {
if f = strings.TrimSpace(f); f != "" {
from = append(from, f)
}
}
if to == "" || len(from) == 0 {
writeProblem(w, req, http.StatusBadRequest, "from and to are required", "")
return
}
// Rebuild each affected note's tag array: map every `from` member to
// `to`, leave everything else alone, then de-duplicate. The dedupe
// matters for the merge case — a note tagged both `422` and
// `proton-422` would otherwise end up with `proton-422` twice.
//
// This is a plain UPDATE on knowledge_entities, so trg_knowledge_revision
// fires and every affected note gets a revision. A tag merge across 17
// notes is exactly the kind of bulk edit worth being able to inspect
// afterwards.
tag, err := s.pool.Exec(ctx, `
UPDATE knowledge_entities ke
SET tags = sub.new_tags, updated_at = now()
FROM (
SELECT k.entity_id,
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
FROM unnest(k.tags) AS t) AS new_tags
FROM knowledge_entities k
WHERE k.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
) AS sub
WHERE ke.entity_id = sub.entity_id`,
lowerAll(from), to)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "rename failed", err.Error())
return
}
_, actorLabel := actorInfo(ctx)
slog.Info("knowledge tags renamed", "from", from, "to", to,
"notes", tag.RowsAffected(), "actor", actorLabel)
writeJSON(w, map[string]any{"ok": true, "notes_updated": tag.RowsAffected()})
}
// serveKnowledgeDuplicates clusters notes whose titles are near-identical.
//
// Pairwise trigram similarity is computed in SQL (indexed, and the whole
// point of pulling in pg_trgm); the grouping is done here in Go. Returning
// clusters rather than pairs matters for the real data: the rclone pileup
// produces dozens of pairs, which is unreadable, versus one cluster, which
// is the actionable unit.
//
// The grouping uses **complete linkage** — a note joins a cluster only if it
// is similar to every member already in it. The obvious implementation
// (union-find over the pairs) is single linkage, and on this data it chains
// badly: "A~B, B~C" merged notes that were not remotely alike, collapsing
// fifteen distinct backup events into one unusable blob. Requiring mutual
// similarity keeps clusters tight enough to act on.
//
// Even so, these are *candidates for review*, never a verdict. The five
// "Lifecycle: <verb> a node" runbooks are mutually similar by title and are
// five deliberately distinct documents — no threshold distinguishes them
// from a genuine duplicate, so merging stays a manual, previewed action.
func (s *Server) serveKnowledgeDuplicates(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
// 0.6, tuned against the live data: at 0.45 the "Lifecycle: <verb> a
// node" runbooks (five deliberately distinct documents that happen to
// share a naming template) formed a false-positive cluster; 0.6 clears
// that down to a single borderline pair while keeping every genuine
// duplicate cluster (the rclone/apt-audit/uptime pileups) intact.
// Tunable per request — the UI exposes this as the review net widens.
threshold := 0.6
if t := req.URL.Query().Get("threshold"); t != "" {
if v, err := strconv.ParseFloat(t, 64); err == nil && v > 0 && v <= 1 {
threshold = v
}
}
rows, err := s.pool.Query(ctx, `
SELECT a.slug, b.slug, similarity(ka.title, kb.title) AS sim
FROM knowledge_entities ka
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
JOIN entities a ON a.id = ka.entity_id
JOIN entities b ON b.id = kb.entity_id
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
AND similarity(ka.title, kb.title) > $1
ORDER BY sim DESC`, threshold)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return
}
defer rows.Close()
type pair struct {
A, B string
Sim float64
}
pairs := []pair{}
for rows.Next() {
var p pair
if err := rows.Scan(&p.A, &p.B, &p.Sim); err != nil {
slog.Error("httpapi: knowledge/duplicates row scan failed", "error", err)
continue
}
pairs = append(pairs, p)
}
// Complete-linkage grouping. `pairs` arrives sorted by similarity
// descending, so each new cluster is seeded from the strongest remaining
// pair and then only grows with notes that are similar to *everything*
// already inside it.
sim := make(map[string]float64, len(pairs)*2)
key := func(a, b string) string {
if a > b {
a, b = b, a
}
return a + "\x00" + b
}
for _, p := range pairs {
sim[key(p.A, p.B)] = p.Sim
}
linked := func(a, b string) bool { return sim[key(a, b)] > 0 }
assigned := map[string]bool{}
type rawCluster struct {
members []string
top float64
}
raw := []rawCluster{}
for _, p := range pairs {
if assigned[p.A] || assigned[p.B] {
continue
}
c := rawCluster{members: []string{p.A, p.B}, top: p.Sim}
assigned[p.A], assigned[p.B] = true, true
// Sweep the remaining pairs for candidates that connect to every
// current member. Repeat until a full pass adds nothing, since
// admitting one member can qualify another.
for grew := true; grew; {
grew = false
for _, q := range pairs {
for _, cand := range []string{q.A, q.B} {
if assigned[cand] {
continue
}
ok := true
for _, m := range c.members {
if !linked(cand, m) {
ok = false
break
}
}
if ok {
c.members = append(c.members, cand)
assigned[cand] = true
grew = true
}
}
}
}
raw = append(raw, c)
}
groups := map[string][]string{}
best := map[string]float64{}
for _, c := range raw {
root := c.members[0]
groups[root] = c.members
best[root] = c.top
}
// Re-fetch display detail for the clustered slugs only.
type member struct {
Slug string `json:"slug"`
Title string `json:"title"`
Kind string `json:"kind"`
Size int `json:"size"`
UpdatedAt string `json:"updated_at"`
EditedBy string `json:"edited_by"`
}
detail := map[string]member{}
if len(groups) > 0 {
all := []string{}
for _, g := range groups {
all = append(all, g...)
}
drows, derr := s.pool.Query(ctx, `
SELECT e.slug, ke.title, e.type, length(ke.content),
ke.updated_at::text, COALESCE(ke.edited_by,'')
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = ANY($1) AND ke.deleted_at IS NULL`, all)
if derr != nil {
writeProblem(w, req, http.StatusInternalServerError, "detail query failed", derr.Error())
return
}
defer drows.Close()
for drows.Next() {
var m member
if err := drows.Scan(&m.Slug, &m.Title, &m.Kind, &m.Size, &m.UpdatedAt, &m.EditedBy); err != nil {
slog.Error("httpapi: knowledge/duplicates detail scan failed", "error", err)
continue
}
detail[m.Slug] = m
}
}
type cluster struct {
Members []member `json:"members"`
TopSim float64 `json:"top_similarity"`
TotalSize int `json:"total_size"`
}
out := []cluster{}
for root, slugs := range groups {
c := cluster{TopSim: best[root]}
for _, sl := range slugs {
if m, ok := detail[sl]; ok {
c.Members = append(c.Members, m)
c.TotalSize += m.Size
}
}
if len(c.Members) < 2 {
continue
}
// Newest first inside a cluster — the most recent note is usually
// the one worth keeping as the merge target.
sort.Slice(c.Members, func(i, j int) bool {
return c.Members[i].UpdatedAt > c.Members[j].UpdatedAt
})
out = append(out, c)
}
// Biggest clusters first: an eight-note pileup deserves attention before
// a two-note coincidence.
sort.Slice(out, func(i, j int) bool {
if len(out[i].Members) != len(out[j].Members) {
return len(out[i].Members) > len(out[j].Members)
}
return out[i].TopSim > out[j].TopSim
})
writeJSON(w, map[string]any{"clusters": out, "threshold": threshold})
}
// serveKnowledgeOrphans surfaces notes that have fallen out of every
// navigation path — the ones that are technically present but effectively
// unreachable, and so quietly stop being maintained.
//
// Three independent reasons, reported per note (a note can have several):
// - untagged: invisible to tag navigation
// - unlinked: not `about` any entity, so it never appears on a machine's page
// - stale: untouched for 90+ days
func (s *Server) serveKnowledgeOrphans(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
staleDays := 90
if d := req.URL.Query().Get("stale_days"); d != "" {
if v, err := strconv.Atoi(d); err == nil && v > 0 && v <= 3650 {
staleDays = v
}
}
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''),
ke.updated_at::text,
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
NOT EXISTS (
SELECT 1 FROM relationships r
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
) AS unlinked,
(ke.updated_at < now() - interval '%d days') AS stale
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.deleted_at IS NULL
ORDER BY ke.updated_at ASC`, staleDays))
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return
}
defer rows.Close()
type orphan struct {
Slug string `json:"slug"`
Title string `json:"title"`
Kind string `json:"kind"`
EditedBy string `json:"edited_by"`
UpdatedAt string `json:"updated_at"`
Reasons []string `json:"reasons"`
}
items := []orphan{}
counts := map[string]int{"untagged": 0, "unlinked": 0, "stale": 0}
for rows.Next() {
var o orphan
var untagged, unlinked, stale bool
if err := rows.Scan(&o.Slug, &o.Title, &o.Kind, &o.EditedBy, &o.UpdatedAt,
&untagged, &unlinked, &stale); err != nil {
slog.Error("httpapi: knowledge/orphans row scan failed", "error", err)
continue
}
o.Reasons = []string{}
if untagged {
o.Reasons = append(o.Reasons, "untagged")
counts["untagged"]++
}
if unlinked {
o.Reasons = append(o.Reasons, "unlinked")
counts["unlinked"]++
}
if stale {
o.Reasons = append(o.Reasons, "stale")
counts["stale"]++
}
if len(o.Reasons) > 0 {
items = append(items, o)
}
}
writeJSON(w, map[string]any{
"items": items,
"counts": counts,
"stale_days": staleDays,
})
}
// serveMergeKnowledge folds several notes into one: each source's body is
// appended to the target under a provenance heading, the union of all tags is
// kept, and the sources are soft-deleted.
//
// Append rather than discard, and soft-delete rather than hard: a merge is a
// judgement call made from a similarity score, and the operator needs to be
// able to walk it back. The target's pre-merge state is captured by the
// revision trigger, so the merge itself is undoable from the History tab.
func (s *Server) serveMergeKnowledge(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
var body struct {
Target string `json:"target"`
Sources []string `json:"sources"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
return
}
if strings.TrimSpace(body.Target) == "" || len(body.Sources) == 0 {
writeProblem(w, req, http.StatusBadRequest, "target and sources are required", "")
return
}
targetID, err := s.resolveKnowledgeEntity(ctx, body.Target)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "target note not found", body.Target)
return
}
_, actorLabel := actorInfo(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
return
}
defer tx.Rollback(ctx)
var merged []string
var appended strings.Builder
tagSet := map[string]bool{}
for _, srcSlug := range body.Sources {
if srcSlug == body.Target {
continue // merging a note into itself would duplicate its body
}
var srcTitle, srcContent, srcUpdated string
var srcTags []string
err := tx.QueryRow(ctx, `
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
srcSlug).Scan(&srcTitle, &srcContent, &srcTags, &srcUpdated)
if err != nil {
slog.Warn("knowledge merge: source not found, skipping", "slug", srcSlug)
continue
}
appended.WriteString("\n\n---\n\n## Merged: ")
appended.WriteString(srcTitle)
appended.WriteString("\n\n*Originally ")
appended.WriteString(srcSlug)
appended.WriteString(", last updated ")
appended.WriteString(srcUpdated)
appended.WriteString("*\n\n")
appended.WriteString(srcContent)
for _, t := range srcTags {
tagSet[strings.ToLower(strings.TrimSpace(t))] = true
}
merged = append(merged, srcSlug)
}
if len(merged) == 0 {
writeProblem(w, req, http.StatusBadRequest, "no valid source notes to merge", "")
return
}
extraTags := make([]string, 0, len(tagSet))
for t := range tagSet {
if t != "" {
extraTags = append(extraTags, t)
}
}
sort.Strings(extraTags)
// The array concat + DISTINCT keeps the target's own tags first and adds
// only what the sources contribute.
if _, err := tx.Exec(ctx, `
UPDATE knowledge_entities
SET content = content || $2,
tags = ARRAY(SELECT DISTINCT unnest(COALESCE(tags,'{}') || $3::text[])),
edited_by = $4,
updated_at = now()
WHERE entity_id = $1`,
targetID, appended.String(), extraTags, actorLabel); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "merge write failed", err.Error())
return
}
for _, srcSlug := range merged {
if _, err := tx.Exec(ctx, `
UPDATE knowledge_entities ke
SET deleted_at = now(), edited_by = $2
FROM entities e
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
srcSlug, actorLabel); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "source delete failed", err.Error())
return
}
}
if err := tx.Commit(ctx); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
return
}
slog.Info("knowledge merged", "target", body.Target, "sources", merged, "actor", actorLabel)
writeJSON(w, map[string]any{"ok": true, "merged": merged, "tags_added": extraTags})
}
// lowerAll is the case-folding helper the tag queries compare against.
func lowerAll(in []string) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = strings.ToLower(strings.TrimSpace(s))
}
return out
}

View File

@@ -0,0 +1,659 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Operator-facing write path for the knowledge base. Until this file, the
// only way anything reached knowledge_entities was the MCP tool
// upsert_knowledge (internal/mcp/server.go) — an agent-only surface. The web
// UI could search and read but never create, correct, or remove a note, so
// the operator's own knowledge had nowhere to go and an agent mistake had no
// fix short of psql.
//
// All routes here are non-OpenAPI custom routes, consistent with the existing
// knowledge read routes (see the carve-out block in server.go): they trade in
// raw markdown and ad-hoc aggregates rather than generated schema types.
//
// Deletion is soft (deleted_at) — see migrations/022_knowledge_revisions.up.sql
// for why — so every read path in this file filters on `ke.deleted_at IS NULL`.
// knowledgeSlugSegmentRe strips a title down to a single slug segment.
// Mirrors knowledgeSlugRe in internal/mcp/server.go; duplicated rather than
// exported across the package boundary because the two callers namespace
// their output differently (see knowledgeSlugFor).
var knowledgeSlugSegmentRe = regexp.MustCompile(`[^a-z0-9]+`)
// knowledgeSlugFor builds `<kind>:<folder>/<title-slug>`. The MCP tool's
// equivalent hardcodes the `nomos/` folder; operator-created notes need to
// land somewhere else so the navigator tree can tell at a glance who wrote
// what, and so an operator note can never collide with an agent note that
// happens to share a title.
func knowledgeSlugFor(kind, folder, title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = knowledgeSlugSegmentRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "note"
}
if len(s) > 80 {
s = s[:80]
}
folder = strings.Trim(strings.ToLower(strings.TrimSpace(folder)), "/")
folder = knowledgeSlugSegmentRe.ReplaceAllString(folder, "-")
folder = strings.Trim(folder, "-")
if folder == "" {
folder = "operator"
}
return kind + ":" + folder + "/" + s
}
// validKnowledgeKind mirrors the three entity types that knowledge_entities
// rows are allowed to hang off (see upsert_knowledge's own check).
func validKnowledgeKind(kind string) bool {
switch kind {
case "document", "investigation", "runbook":
return true
}
return false
}
// resolveKnowledgeEntity maps an id-or-slug path segment to the entity id of
// a live (non-deleted) knowledge note. Returns pgx.ErrNoRows when there's no
// such note, which callers turn into a 404.
func (s *Server) resolveKnowledgeEntity(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx, `
SELECT ke.entity_id
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE (e.slug = $1 OR e.id::text = $1)
AND ke.deleted_at IS NULL`, idOrSlug).Scan(&id)
return id, err
}
// resolveKnowledgeEntityAny is resolveKnowledgeEntity without the
// deleted_at filter — for the one read path (revisions) that must still work
// on a deleted note. The whole point of soft-delete is that a note's history
// stays inspectable after removal (e.g. to confirm what was lost before
// restoring it); requiring the note to be live first would defeat that.
func (s *Server) resolveKnowledgeEntityAny(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx, `
SELECT ke.entity_id
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).Scan(&id)
return id, err
}
// pathParam pulls a chi URL param and percent-decodes it. Knowledge slugs
// contain both ':' and '/' (e.g. "document:containers/101-jellyfin"), so they
// reach the handler still encoded — chi.URLParam does no decoding of its own
// on manually-registered routes (unlike the OpenAPI-generated ones, which
// decode via runtime.BindStyledParameterWithOptions).
func pathParam(req *http.Request, name string) (string, error) {
return url.PathUnescape(chi.URLParam(req, name))
}
// serveKnowledgeList returns every live note without its body — the backing
// data for the wiki navigator tree. Distinct from /knowledge/recent, which
// caps at 200 and exists to answer "what changed lately" for the stats view:
// the tree needs the complete set, and needs the linked-entity slugs so it
// can offer a group-by-entity arrangement without N+1 fetches.
//
// Body text is deliberately excluded — with ~100 notes averaging ~1 KB the
// full payload would be ~100 KB per app open, to render a list that shows
// only titles.
func (s *Server) serveKnowledgeList(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
type item struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Kind string `json:"kind"`
Source string `json:"source"`
EditedBy string `json:"edited_by"`
Tags []string `json:"tags"`
About []string `json:"about"`
Size int `json:"size"`
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
Revisions int `json:"revisions"`
}
// The `about` aggregate mirrors GetEntityKnowledge's first UNION branch
// (documents/about edges) — the 'procedure-for' branch is left out here
// because it joins against entity *types* rather than entities and can't
// produce a per-note slug list.
rows, err := s.pool.Query(ctx, `
SELECT e.id::text, e.slug, ke.title, e.type, COALESCE(ke.source,''),
COALESCE(ke.edited_by,''), COALESCE(ke.tags, '{}'),
COALESCE((
SELECT array_agg(DISTINCT t.slug)
FROM relationships r
JOIN entities t ON t.id = r.target_id
WHERE r.source_id = ke.entity_id
AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
), '{}'),
length(ke.content),
ke.updated_at::text, ke.created_at::text,
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.deleted_at IS NULL
ORDER BY ke.updated_at DESC`)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return
}
defer rows.Close()
items := []item{}
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Kind, &it.Source,
&it.EditedBy, &it.Tags, &it.About, &it.Size,
&it.UpdatedAt, &it.CreatedAt, &it.Revisions); err != nil {
slog.Error("httpapi: knowledge/list row scan failed", "error", err)
continue
}
items = append(items, it)
}
writeJSON(w, map[string]any{"items": items})
}
// serveKnowledgeTrash lists soft-deleted notes — the counterpart to
// serveKnowledgeList, and what the "restore" affordance in the UI browses.
// Without this, a deleted note is invisible from every list endpoint
// (correctly — they all filter deleted_at) with no way to even discover it
// exists to restore.
func (s *Server) serveKnowledgeTrash(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
rows, err := s.pool.Query(ctx, `
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.deleted_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.deleted_at IS NOT NULL
ORDER BY ke.deleted_at DESC`)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return
}
defer rows.Close()
type item struct {
Slug string `json:"slug"`
Title string `json:"title"`
Kind string `json:"kind"`
DeletedBy string `json:"deleted_by"`
DeletedAt string `json:"deleted_at"`
}
items := []item{}
for rows.Next() {
var it item
if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &it.DeletedBy, &it.DeletedAt); err != nil {
slog.Error("httpapi: knowledge/trash row scan failed", "error", err)
continue
}
items = append(items, it)
}
writeJSON(w, map[string]any{"items": items})
}
// knowledgeWriteBody is the shared request shape for create and update.
// Every field is a pointer so update can distinguish "not supplied" (leave
// alone) from "supplied empty" (clear it) — a PUT that only changes tags
// must not blank the body.
type knowledgeWriteBody struct {
Title *string `json:"title"`
Content *string `json:"content"`
Kind *string `json:"kind"`
Tags *[]string `json:"tags"`
Folder *string `json:"folder"`
About *[]string `json:"about"`
}
// serveCreateKnowledge creates a note plus its backing entity, and links it
// to whatever entities it's about.
func (s *Server) serveCreateKnowledge(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
var body knowledgeWriteBody
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
return
}
title := strings.TrimSpace(deref(body.Title))
content := strings.TrimSpace(deref(body.Content))
if title == "" || content == "" {
writeProblem(w, req, http.StatusBadRequest, "title and content are required", "")
return
}
kind := deref(body.Kind)
if kind == "" {
kind = "document"
}
if !validKnowledgeKind(kind) {
writeProblem(w, req, http.StatusBadRequest, "invalid kind",
"kind must be document, investigation, or runbook")
return
}
tags := normalizeTags(derefSlice(body.Tags))
slug := knowledgeSlugFor(kind, deref(body.Folder), title)
_, actorLabel := actorInfo(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
return
}
defer tx.Rollback(ctx)
docID, _ := uuid.NewV7()
// ON CONFLICT covers the soft-deleted case: the entity row survives a
// delete, so recreating a note under the same slug must reuse it rather
// than fail the unique constraint.
if err := tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, $3, $4, '{}')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "create entity failed", err.Error())
return
}
// Refuse to silently overwrite an existing LIVE note — upsert_knowledge
// (the MCP tool) deliberately upserts by title (the agent re-records the
// same finding as it learns more), but an operator hitting "create" with
// a colliding title almost certainly means to write something new.
//
// The `WHERE knowledge_entities.deleted_at IS NOT NULL` guard makes this
// check atomic with the write, rather than a separate SELECT before it:
// a plain pre-check has a TOCTOU race where two concurrent creates of
// the same title can both pass the check and then both proceed to
// INSERT ON CONFLICT DO UPDATE, silently clobbering each other. Here,
// the UPDATE branch only actually applies when the conflicting row is
// soft-deleted (a legitimate "resurrect" case). When it isn't, the row
// is left untouched, RETURNING yields no row, and pgx.ErrNoRows below
// becomes the 409 — the collision can never be missed, no matter how
// the two writers interleave.
var wroteID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO knowledge_entities
(entity_id, title, content, source, tags, edited_by, updated_at, deleted_at)
VALUES ($1, $2, $3, $4, $5, $4, now(), NULL)
ON CONFLICT (entity_id) DO UPDATE
SET title = EXCLUDED.title, content = EXCLUDED.content,
tags = EXCLUDED.tags, edited_by = EXCLUDED.edited_by,
updated_at = now(), deleted_at = NULL
WHERE knowledge_entities.deleted_at IS NOT NULL
RETURNING entity_id`,
docID, title, content, actorLabel, tags).Scan(&wroteID)
if errors.Is(err, pgx.ErrNoRows) {
writeProblem(w, req, http.StatusConflict, "a note with this title already exists", slug)
return
} else if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "write knowledge failed", err.Error())
return
}
linked := s.linkKnowledgeAbout(ctx, tx, docID, derefSlice(body.About))
if err := tx.Commit(ctx); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
return
}
slog.Info("knowledge created", "slug", slug, "kind", kind, "actor", actorLabel, "linked", linked)
// Content-Type before WriteHeader — setting it after is a no-op, the
// status line is already on the wire.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]any{
"slug": slug, "id": docID.String(), "linked": linked,
}); err != nil {
slog.Error("httpapi: json encode failed", "error", err)
}
}
// serveUpdateKnowledge edits a live note in place. The prior version is
// captured by the trg_knowledge_revision trigger, not by this handler — see
// the migration for why that lives in the database.
//
// Note the slug is intentionally NOT recomputed when the title changes:
// slugs are the wiki's stable link target ([[slug]] references, relationship
// rows, bookmarked window ids), and silently re-slugging on a typo fix would
// break every inbound link.
func (s *Server) serveUpdateKnowledge(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
idOrSlug, err := pathParam(req, "id")
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
return
}
var body knowledgeWriteBody
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
return
}
if body.Title == nil && body.Content == nil && body.Tags == nil && body.About == nil {
writeProblem(w, req, http.StatusBadRequest, "nothing to update",
"supply at least one of title, content, tags, about")
return
}
if body.Title != nil && strings.TrimSpace(*body.Title) == "" {
writeProblem(w, req, http.StatusBadRequest, "title cannot be empty", "")
return
}
if body.Content != nil && strings.TrimSpace(*body.Content) == "" {
writeProblem(w, req, http.StatusBadRequest, "content cannot be empty", "")
return
}
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
return
}
_, actorLabel := actorInfo(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
return
}
defer tx.Rollback(ctx)
// COALESCE keeps unsupplied fields untouched; edited_by and updated_at
// always move so the UI can show who last touched it. The trigger only
// snapshots when title/content/tags actually differ, so a no-op save
// doesn't manufacture a revision.
var newTitle *string
if body.Title != nil {
t := strings.TrimSpace(*body.Title)
newTitle = &t
}
var newContent *string
if body.Content != nil {
c := strings.TrimSpace(*body.Content)
newContent = &c
}
var newTags *[]string
if body.Tags != nil {
t := normalizeTags(*body.Tags)
newTags = &t
}
if _, err := tx.Exec(ctx, `
UPDATE knowledge_entities
SET title = COALESCE($2, title),
content = COALESCE($3, content),
tags = COALESCE($4, tags),
edited_by = $5,
updated_at = now()
WHERE entity_id = $1`,
entityID, newTitle, newContent, newTags, actorLabel); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "update failed", err.Error())
return
}
// Keep the entity's display name in step with the note title — the graph
// and the fleet table read entities.name, and leaving it stale is exactly
// the drift this app exists to fight.
if newTitle != nil {
if _, err := tx.Exec(ctx,
`UPDATE entities SET name = $2, updated_at = now() WHERE id = $1`,
entityID, *newTitle); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "rename entity failed", err.Error())
return
}
}
// About is replace-semantics, not merge: the editor presents the full
// link set, so an absent slug means the operator removed it. Existing
// edges are closed (valid_to) rather than deleted, preserving history.
var linked []string
if body.About != nil {
if _, err := tx.Exec(ctx, `
UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND valid_to IS NULL AND type IN ('documents', 'about')`,
entityID); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "unlink failed", err.Error())
return
}
linked = s.linkKnowledgeAbout(ctx, tx, entityID, *body.About)
}
if err := tx.Commit(ctx); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
return
}
slog.Info("knowledge updated", "entity_id", entityID, "actor", actorLabel)
// `linked` lets the caller diff against what it submitted and warn about
// any slug that didn't resolve — see linkKnowledgeAbout: a typo'd entity
// slug otherwise fails with nothing but a server-side slog.Warn, so the
// operator gets no feedback that one of their About links didn't take.
writeJSON(w, map[string]any{"ok": true, "linked": linked})
}
// serveDeleteKnowledge soft-deletes a note. The row, its revision trail and
// its entity all survive; only the deleted_at stamp changes, and every read
// path filters on it.
func (s *Server) serveDeleteKnowledge(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
idOrSlug, err := pathParam(req, "id")
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
return
}
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
return
}
_, actorLabel := actorInfo(ctx)
// Snapshot the live version before tombstoning. The trigger fires on
// title/content/tags changes only, and a delete changes none of them —
// without this the most recent version would be the one version missing
// from the history if the note is later restored.
if _, err := s.pool.Exec(ctx, `
INSERT INTO knowledge_revisions
(entity_id, title, content, source, tags, edited_by, version_at)
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
FROM knowledge_entities WHERE entity_id = $1`, entityID); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "snapshot failed", err.Error())
return
}
if _, err := s.pool.Exec(ctx, `
UPDATE knowledge_entities SET deleted_at = now(), edited_by = $2
WHERE entity_id = $1`, entityID, actorLabel); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "delete failed", err.Error())
return
}
slog.Info("knowledge deleted", "entity_id", entityID, "actor", actorLabel)
writeJSON(w, map[string]any{"ok": true})
}
// serveRestoreKnowledge undoes a soft delete. The counterpart to
// serveDeleteKnowledge — without it, "recoverable by clearing the column"
// (see the migration) would only be true via psql, which isn't a real
// recovery path for an operator using the wiki.
func (s *Server) serveRestoreKnowledge(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
idOrSlug, err := pathParam(req, "id")
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
return
}
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
return
}
_, actorLabel := actorInfo(ctx)
ct, err := s.pool.Exec(ctx, `
UPDATE knowledge_entities SET deleted_at = NULL, edited_by = $2
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID, actorLabel)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "restore failed", err.Error())
return
}
if ct.RowsAffected() == 0 {
writeProblem(w, req, http.StatusConflict, "note is not deleted", "")
return
}
slog.Info("knowledge restored", "entity_id", entityID, "actor", actorLabel)
writeJSON(w, map[string]any{"ok": true})
}
// serveKnowledgeRevisions returns the note's superseded versions, newest
// first. Bodies are included: revisions are small (~1 KB) and few, and the
// diff view needs both sides anyway — paginating would cost a round trip per
// comparison to save nothing.
func (s *Server) serveKnowledgeRevisions(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
idOrSlug, err := pathParam(req, "id")
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
return
}
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
return
}
rows, err := s.pool.Query(ctx, `
SELECT id, title, content, COALESCE(edited_by,''), COALESCE(tags,'{}'),
version_at::text, revised_at::text
FROM knowledge_revisions
WHERE entity_id = $1
ORDER BY version_at DESC`, entityID)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
return
}
defer rows.Close()
type revision struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
EditedBy string `json:"edited_by"`
Tags []string `json:"tags"`
VersionAt string `json:"version_at"`
RevisedAt string `json:"revised_at"`
}
items := []revision{}
for rows.Next() {
var r revision
if err := rows.Scan(&r.ID, &r.Title, &r.Content, &r.EditedBy, &r.Tags,
&r.VersionAt, &r.RevisedAt); err != nil {
slog.Error("httpapi: knowledge/revisions row scan failed", "error", err)
continue
}
items = append(items, r)
}
writeJSON(w, map[string]any{"items": items})
}
// linkKnowledgeAbout points a note at the entities it concerns, skipping
// slugs that don't resolve and edges that already exist. Returns the slugs
// actually linked so the caller can report what stuck — a typo'd slug is a
// silent no-op otherwise.
func (s *Server) linkKnowledgeAbout(ctx context.Context, tx pgx.Tx, docID uuid.UUID, slugs []string) []string {
linked := []string{}
for _, raw := range slugs {
slug := strings.TrimSpace(raw)
if slug == "" {
continue
}
var targetID uuid.UUID
if err := tx.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&targetID); err != nil {
slog.Warn("knowledge: about slug not found, skipping", "slug", slug)
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'about', '{"by":"operator"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'about' AND valid_to IS NULL)`,
docID, targetID); err != nil {
slog.Warn("knowledge: link failed", "slug", slug, "error", err)
continue
}
linked = append(linked, slug)
}
return linked
}
// normalizeTags trims, lowercases and de-duplicates while preserving order.
// Lowercasing is the fix for the casing drift already in the data — `oom`
// and `OOM` were separate tags on separate notes, so neither tag page showed
// the full set. Applied on every write so the split can't reopen.
func normalizeTags(in []string) []string {
seen := map[string]bool{}
out := []string{}
for _, t := range in {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
func derefSlice(p *[]string) []string {
if p == nil {
return nil
}
return *p
}
// writeJSON is the success-path counterpart to writeProblem, so the handlers
// in this file don't each repeat the header/encode dance.
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("httpapi: json encode failed", "error", err)
}
}

View File

@@ -109,6 +109,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
// /api/v1/knowledge/list — full tree listing, ad-hoc aggregate
// /api/v1/knowledge (POST) — markdown in, no gen type
// /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
// /api/v1/knowledge/trash — soft-deleted notes, ad-hoc
// /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
// /api/v1/knowledge/revisions/{id} — version history, no schema type
// /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
// /api/v1/knowledge/duplicates — trigram clustering, ad-hoc
// /api/v1/knowledge/orphans — derived maintenance view
// /api/v1/knowledge/merge — bulk fold-in, ad-hoc
// /api/v1/activity/recent — recency-ordered, not paginated
// /api/v1/activity/session/{id} — session-scoped aggregation
// /api/v1/learning/timeline — derived view, no backing schema type
@@ -202,6 +212,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
// Custom (non-OpenAPI) routes: the operator-facing knowledge CRUD surface
// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
// knowledge_drift.go). Before these, knowledge could only be written by
// the agent through the MCP upsert_knowledge tool — the web UI had no way
// to create, correct or retire a note.
//
// Registered on the base router rather than through the OpenAPI codegen
// for the same reason as the read routes above: they trade in raw
// markdown and ad-hoc aggregates, not generated schema types.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
// unlike ListExecutions which sorts by target for pagination) and the
// per-session "what did this session do" digest.

View File

@@ -0,0 +1,119 @@
-- 022_knowledge_revisions.up.sql
-- Version history for knowledge_entities, so an edit can never be silently lost.
--
-- The concrete hazard this closes: the MCP tool `upsert_knowledge`
-- (internal/mcp/server.go) keys on title and does
-- `ON CONFLICT (entity_id) DO UPDATE SET content = EXCLUDED.content` —
-- unconditionally. Before this migration, an operator hand-editing a note in
-- the web UI would have that edit overwritten with no trace the next time
-- Nomos re-upserted a note with the same title. There was no history table
-- and no way to recover the prior body.
--
-- The snapshot is a BEFORE UPDATE **trigger** rather than application-level
-- code in the HTTP handler, specifically because there are two independent
-- writers: the web API (new in this change) and the MCP tool the agent uses.
-- App-level snapshotting would only cover whichever path remembered to call
-- it. A trigger covers both, plus any future writer and any manual psql fix.
--
-- Each row in knowledge_revisions is a *superseded* version: the state of the
-- note before the update that displaced it. The current version always lives
-- in knowledge_entities, never here, so "history" is
-- knowledge_entities + knowledge_revisions ordered by version_at DESC.
-- Who authored the version currently in knowledge_entities. Distinct from
-- `source`, which is overloaded: it holds either 'nomos-agent' (written via
-- MCP) or a seed file path ('containers/101-jellyfin') and is NOT updated on
-- conflict, so a seeded doc later rewritten by the agent still reports its
-- original file path. edited_by answers the question the UI actually asks —
-- "did a human or the agent last touch this?" — without disturbing source,
-- which the seeding logic still relies on.
ALTER TABLE knowledge_entities
ADD COLUMN IF NOT EXISTS edited_by TEXT NOT NULL DEFAULT '';
-- Backfill: every existing row's last writer is whatever source says. For
-- agent-written notes that's exactly right; for seeded notes it records the
-- seed path, which is the honest answer (no human has edited them yet).
UPDATE knowledge_entities
SET edited_by = COALESCE(source, '')
WHERE edited_by = '';
-- Soft delete. A hard DELETE would cascade knowledge_revisions away with the
-- entity, which contradicts the point of this migration — removing a note is
-- exactly the moment its history matters most. Deleting sets deleted_at; all
-- read paths filter it out, the revision trail survives, and an accidental
-- delete is recoverable by clearing the column.
ALTER TABLE knowledge_entities
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Partial index: every list/search/read query carries `deleted_at IS NULL`,
-- and deleted notes are expected to stay a small minority.
CREATE INDEX IF NOT EXISTS idx_knowledge_live
ON knowledge_entities (updated_at DESC)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS knowledge_revisions (
id BIGSERIAL PRIMARY KEY,
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT,
tags TEXT[],
edited_by TEXT NOT NULL DEFAULT '',
-- When this version was written (the superseded row's updated_at).
version_at TIMESTAMPTZ NOT NULL,
-- When it was replaced. version_at of revision N and revised_at of
-- revision N-1 bracket how long that version was the live one.
revised_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The only access pattern: "show me the history of this note, newest first."
CREATE INDEX IF NOT EXISTS idx_knowledge_revisions_entity
ON knowledge_revisions (entity_id, version_at DESC);
-- Snapshot the outgoing row whenever the substance changes. Deliberately
-- ignores updated_at-only touches: upsert_knowledge sets `updated_at = now()`
-- on every call even when re-writing byte-identical content (it has no
-- change detection), and without this guard a re-run of the same agent task
-- would pile up identical revisions and bury the real edits.
--
-- `search` is a GENERATED column and is intentionally not carried into
-- revisions — it is derived from title+content and would be dead weight.
CREATE OR REPLACE FUNCTION snapshot_knowledge_revision() RETURNS TRIGGER AS $$
BEGIN
IF OLD.title IS DISTINCT FROM NEW.title
OR OLD.content IS DISTINCT FROM NEW.content
OR OLD.tags IS DISTINCT FROM NEW.tags THEN
INSERT INTO knowledge_revisions
(entity_id, title, content, source, tags, edited_by, version_at)
VALUES
(OLD.entity_id, OLD.title, OLD.content, OLD.source, OLD.tags,
OLD.edited_by, OLD.updated_at);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- DROP + CREATE rather than CREATE OR REPLACE: Postgres 16 has no
-- CREATE OR REPLACE TRIGGER for this form, and the migration must stay
-- re-runnable.
DROP TRIGGER IF EXISTS trg_knowledge_revision ON knowledge_entities;
CREATE TRIGGER trg_knowledge_revision
BEFORE UPDATE ON knowledge_entities
FOR EACH ROW EXECUTE FUNCTION snapshot_knowledge_revision();
-- Trigram similarity, for the duplicate-detection view. The knowledge base
-- has already accumulated near-duplicates that exact matching cannot catch —
-- four separate "rclone backup live inspection — <date>" investigations, each
-- a fresh note where an update to the existing one was meant. upsert_knowledge
-- keys on exact title, so a date suffix is enough to fork a new note.
--
-- similarity() over titles is what lets the UI cluster those and offer a
-- merge. fuzzystrmatch (levenshtein) was the alternative; trigram wins here
-- because these titles differ by whole appended words rather than typos, and
-- because it comes with a GIN index while levenshtein cannot be indexed.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_knowledge_title_trgm
ON knowledge_entities USING gin (title gin_trgm_ops)
WHERE deleted_at IS NULL;

View File

@@ -333,3 +333,92 @@ a:hover {
border-left: 1px solid var(--border);
cursor: col-resize;
}
/* Base markdown rendering — used by every {@html marked.parse(...)} output
(EntityDetailContent, the Knowledge wiki's WikiReader, and as the
foundation ChatThread's fuller "Art Nouveau" chat styling builds on top
of). Global rather than a per-component <style> block: Svelte scopes
<style> to one component, so three separate copies of this same ~50-line
ruleset had accumulated (EntityDetailContent's copy was already a
documented "can't share, Svelte scopes styles" duplicate of ChatThread's,
and WikiReader added a third when the Knowledge wiki was built). Anything
that renders sanitized markdown into an .markdown-body container gets
this for free; a component only needs its own <style> block for looks
that genuinely diverge from this baseline (see ChatThread.svelte's
trimmed-down block for the pattern: same class, only the deltas kept,
using a two-class selector so its overrides win on specificity rather
than depending on <style> injection order).
Includes explicit list-style-type — Tailwind's preflight reset (@import
'tailwindcss' above) strips it from every <ul>/<ol>, so without this,
markdown bullet/numbered lists silently render with no markers. */
.markdown-body p {
margin: 0 0 0.5rem;
}
.markdown-body p:last-child {
margin-bottom: 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.markdown-body ul {
list-style-type: disc;
}
.markdown-body ol {
list-style-type: decimal;
}
.markdown-body li {
margin-bottom: 0.125rem;
}
.markdown-body code {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.markdown-body pre {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.markdown-body table {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.markdown-body blockquote {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
.markdown-body a {
color: var(--primary);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}

View File

@@ -675,8 +675,10 @@ export interface KnowledgeContent {
title: string
content: string
source: string
edited_by: string
tags: string[]
updated_at: string
revisions: number
}
// Full markdown body for a document/investigation/runbook entity — distinct
@@ -688,6 +690,259 @@ export async function fetchKnowledgeContent(id: string): Promise<KnowledgeConten
return res.json()
}
// ─── Knowledge wiki: write path + drift tooling ────────────────────────────
//
// Everything below this line talks to internal/httpapi/knowledge_write.go
// and knowledge_drift.go — the operator-facing CRUD surface added alongside
// the wiki redesign. Before this, the only writer was the MCP tool the agent
// uses; the web UI could search and read but never create, correct, or
// retire a note.
//
// Mutations throw KnowledgeApiError on failure instead of returning null —
// unlike the read helpers above, a write failure usually has a specific,
// user-facing reason (409 "a note with this title already exists", 400
// "content cannot be empty") that the caller needs to display, not just a
// generic "something went wrong."
// Mirrors the RFC7807 problem+json shape internal/httpapi/problem.go writes.
export class KnowledgeApiError extends Error {
status: number
detail: string
constructor(status: number, title: string, detail: string) {
super(title)
this.status = status
this.detail = detail
}
}
async function parseKnowledgeError(res: Response): Promise<never> {
let title = `request failed (${res.status})`
let detail = ''
try {
const body = await res.json()
title = body.title ?? title
detail = body.detail ?? ''
} catch {
// non-JSON error body — fall back to the generic title above
}
throw new KnowledgeApiError(res.status, title, detail)
}
export interface KnowledgeListItem {
id: string
slug: string
title: string
kind: 'document' | 'runbook' | 'investigation'
source: string
edited_by: string
tags: string[]
about: string[]
size: number
updated_at: string
created_at: string
revisions: number
}
// The full live set, body-free — backs the wiki navigator tree. Distinct
// from fetchRecentKnowledge, which caps at 200 and drives the stats/recency
// view; the tree needs every note plus linked-entity slugs for the
// group-by-entity arrangement.
// Throws KnowledgeApiError on failure rather than returning [] — an empty
// list here must mean "the collection really is empty," never "the request
// failed." Silently treating a 500/network error as [] previously left the
// whole wiki reporting "0 notes" indistinguishable from an actual outage;
// see Knowledge.svelte's loadItems for how the caller surfaces this.
export async function listKnowledge(): Promise<KnowledgeListItem[]> {
const res = await fetchWithAuth(`${API}/knowledge/list`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeWriteInput {
title?: string
content?: string
kind?: 'document' | 'investigation' | 'runbook'
tags?: string[]
folder?: string
about?: string[]
}
export async function createKnowledge(
input: KnowledgeWriteInput
): Promise<{ slug: string; id: string; linked: string[] }> {
const res = await fetchWithAuth(`${API}/knowledge`, {
method: 'POST',
body: JSON.stringify(input)
})
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
// idOrSlug identifies the note; only the fields present in `input` are
// changed (undefined = leave alone), matching the PUT handler's COALESCE
// semantics — see knowledge_write.go's serveUpdateKnowledge.
// `linked` echoes back which `about` slugs actually resolved (only present
// when `input.about` was supplied) — a typo'd entity slug otherwise fails
// server-side with nothing but a log line, so the caller can diff this
// against what it sent and warn about anything that silently didn't take.
export async function updateKnowledge(
idOrSlug: string,
input: KnowledgeWriteInput
): Promise<{ linked?: string[] }> {
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
method: 'PUT',
body: JSON.stringify(input)
})
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
// Soft delete — the note moves to the trash (fetchKnowledgeTrash) and can be
// brought back with restoreKnowledge. Never a hard, unrecoverable delete.
export async function deleteKnowledge(idOrSlug: string): Promise<void> {
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
})
if (!res.ok) return parseKnowledgeError(res)
}
export async function restoreKnowledge(idOrSlug: string): Promise<void> {
const res = await fetchWithAuth(`${API}/knowledge/restore/${encodeURIComponent(idOrSlug)}`, {
method: 'POST'
})
if (!res.ok) return parseKnowledgeError(res)
}
export interface KnowledgeTrashItem {
slug: string
title: string
kind: string
deleted_by: string
deleted_at: string
}
// Throws on failure — see listKnowledge's comment on why "empty" and
// "failed" must not collapse into the same [].
export async function fetchKnowledgeTrash(): Promise<KnowledgeTrashItem[]> {
const res = await fetchWithAuth(`${API}/knowledge/trash`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeRevision {
id: number
title: string
content: string
edited_by: string
tags: string[]
version_at: string
revised_at: string
}
// Newest first. Works even for a soft-deleted note — inspecting what was
// lost is exactly when history matters most (see resolveKnowledgeEntityAny
// in knowledge_write.go).
export async function fetchKnowledgeRevisions(idOrSlug: string): Promise<KnowledgeRevision[]> {
const res = await fetchWithAuth(`${API}/knowledge/revisions/${encodeURIComponent(idOrSlug)}`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeTag {
tag: string
uses: number
variants: string[]
// True when the same tag is stored under more than one casing (e.g.
// "oom" / "OOM") — the tag manager badges these as needing a normalize.
split: boolean
}
export async function fetchKnowledgeTags(): Promise<KnowledgeTag[]> {
const res = await fetchWithAuth(`${API}/knowledge/tags`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
// Rewrites every `from` tag to `to` across all live notes. Pass several
// `from` values to merge them into one; pass a tag's own case variants to
// normalize casing.
export async function renameKnowledgeTag(from: string[], to: string): Promise<number> {
const res = await fetchWithAuth(`${API}/knowledge/tags/rename`, {
method: 'POST',
body: JSON.stringify({ from, to })
})
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.notes_updated ?? 0
}
export interface KnowledgeDuplicateMember {
slug: string
title: string
kind: string
size: number
updated_at: string
edited_by: string
}
export interface KnowledgeDuplicateCluster {
members: KnowledgeDuplicateMember[]
top_similarity: number
total_size: number
}
// Title-similarity clusters — candidates for review, never a verdict. See
// the Go handler: notes that share a naming template (e.g. the five
// "Lifecycle: <verb> a node" runbooks) can cluster here despite being
// genuinely distinct documents, so the UI must let the operator inspect
// each cluster rather than offering a blind "merge all."
export async function fetchKnowledgeDuplicates(
threshold?: number
): Promise<KnowledgeDuplicateCluster[]> {
const params = threshold ? `?threshold=${threshold}` : ''
const res = await fetchWithAuth(`${API}/knowledge/duplicates${params}`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.clusters ?? []
}
export interface KnowledgeOrphan {
slug: string
title: string
kind: string
edited_by: string
updated_at: string
reasons: ('untagged' | 'unlinked' | 'stale')[]
}
export async function fetchKnowledgeOrphans(
staleDays?: number
): Promise<{ items: KnowledgeOrphan[]; counts: Record<string, number> }> {
const params = staleDays ? `?stale_days=${staleDays}` : ''
const res = await fetchWithAuth(`${API}/knowledge/orphans${params}`)
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
// Folds `sources` into `target`: each source's body is appended under a
// provenance heading, tags are unioned, and the sources are soft-deleted
// (recoverable from trash, same as a plain delete).
export async function mergeKnowledge(
target: string,
sources: string[]
): Promise<{ merged: string[]; tags_added: string[] }> {
const res = await fetchWithAuth(`${API}/knowledge/merge`, {
method: 'POST',
body: JSON.stringify({ target, sources })
})
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
export async function fetchEntityEvents(
entityId: string
): Promise<import('./stores/events').OikosEvent[]> {

View File

@@ -274,7 +274,9 @@
/>
{/if}
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<div
class="markdown-body prose-chat max-w-none text-sm leading-relaxed assistant-msg"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if isLast && streaming}
@@ -416,50 +418,36 @@
overflow-wrap: break-word;
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
/* Prose overrides — deltas on top of the shared .markdown-body base
(app.css) only. The template applies both classes together
(class="markdown-body prose-chat ..."); everything below either adds a
look .markdown-body doesn't have (li::marker, the pre/blockquote
::before ornaments, hr, strong, the table-wrapper, the code-copy
button) or overrides a .markdown-body value that this "Art Nouveau"
chat treatment wants different (code/pre padding, heading size, th/td
padding, blockquote border color, link underline style). Anywhere a
value is actually overridden, the selector is
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
:global() selectors from two different <style> blocks land in the same
stylesheet with no scoping to arbitrate between them, so equal
specificity would leave the winner to injection order (unreliable
across dev/build). The two-class selector's higher specificity wins
deterministically regardless. */
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
.markdown-body.prose-chat :global(code) {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
.markdown-body.prose-chat :global(pre) {
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
@@ -473,34 +461,28 @@
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1) {
.markdown-body.prose-chat :global(h1) {
font-size: 1.15em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h2) {
.markdown-body.prose-chat :global(h2) {
font-size: 1.08em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h3) {
.markdown-body.prose-chat :global(h3) {
font-size: 1.02em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
@@ -524,11 +506,6 @@
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(.table-wrapper) {
overflow-x: auto;
margin: 0 0 0.5rem;
@@ -540,18 +517,13 @@
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
.markdown-body.prose-chat :global(th),
.markdown-body.prose-chat :global(td) {
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
.markdown-body.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
@@ -587,8 +559,7 @@
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
.markdown-body.prose-chat :global(a) {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;

View File

@@ -350,8 +350,10 @@
{#snippet contentContent()}
{#if ownContent}
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
<div class="markdown-body max-w-none text-xs">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html renderMarkdown(ownContent.content)}
</div>
{:else}
<p class="text-xs text-muted-foreground">No content.</p>
{/if}
@@ -693,67 +695,3 @@
{/each}
{/if}
</div>
<style>
/* Minimal markdown styling for document/investigation/runbook content —
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
so it can't be shared directly). */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>

View File

@@ -0,0 +1,570 @@
<script lang="ts">
// Maintenance view for the knowledge base's own drift — duplicates, tag
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
// rather than folded into the main three-pane view because none of this
// is "browse a note," it's "audit the collection," and mixing the two
// would clutter the read/edit flow with tools most visits don't need.
//
// Every action here (merge, rename, restore) is deliberately one click
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
// own doc comment on why title-similarity clustering can't be trusted as
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
// being genuinely distinct documents).
import {
fetchKnowledgeDuplicates,
fetchKnowledgeTags,
fetchKnowledgeOrphans,
fetchKnowledgeTrash,
renameKnowledgeTag,
mergeKnowledge,
restoreKnowledge,
KnowledgeApiError,
type KnowledgeDuplicateCluster,
type KnowledgeTag,
type KnowledgeOrphan,
type KnowledgeTrashItem
} from '$lib/api'
import * as Tabs from '$lib/components/ui/tabs'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import Spinner from '$lib/components/Spinner.svelte'
import { toast } from 'svelte-sonner'
import { kindMeta } from './kinds'
import { relativeTime } from '$lib/utils'
import CopyIcon from '@lucide/svelte/icons/copy'
import TagIcon from '@lucide/svelte/icons/tag'
import GhostIcon from '@lucide/svelte/icons/ghost'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import CheckIcon from '@lucide/svelte/icons/check'
let {
onSelect,
onChanged
}: {
onSelect: (slug: string) => void
// Fired after any mutation (merge, tag rename, restore) so the parent's
// note list — which this view reads a filtered copy of, indirectly —
// stays in sync.
onChanged: () => void
} = $props()
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
// Shared across every loader/action below: the read helpers in api.ts now
// throw KnowledgeApiError on a failed request instead of quietly returning
// an empty list, so a real outage can't be mistaken for "nothing to
// clean up" — see api.ts's comment on listKnowledge for the same fix
// applied to the main note list.
function errMsg(e: unknown): string {
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
}
// ─── Duplicates ───────────────────────────────────────────────────────
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
let duplicatesError = $state('')
// Per cluster (indexed by the cluster's first member slug — stable across
// a reload since clusters are keyed by content, not array position):
// which slug is the merge target and which sources are checked.
let mergeTarget = $state<Record<string, string>>({})
let mergeSources = $state<Record<string, Set<string>>>({})
let merging = $state<string | null>(null)
let mergeError = $state('')
async function loadDuplicates(): Promise<void> {
clusters = null
duplicatesError = ''
try {
const result = await fetchKnowledgeDuplicates()
clusters = result
const targets: Record<string, string> = {}
const sources: Record<string, Set<string>> = {}
for (const c of result) {
const key = c.members[0].slug
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
}
mergeTarget = targets
mergeSources = sources
} catch (e) {
duplicatesError = errMsg(e)
clusters = [] // stop the spinner — the error message above explains the empty state
}
}
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
// `sources` since it used to be excluded as "the target"), so recompute
// the whole source set relative to the new target rather than leaving it
// stale — otherwise the old target silently drops out of the merge
// instead of folding in like every other member.
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
mergeSources = {
...mergeSources,
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
}
}
function toggleSource(clusterKey: string, slug: string): void {
const set = new Set(mergeSources[clusterKey])
if (set.has(slug)) set.delete(slug)
else set.add(slug)
mergeSources = { ...mergeSources, [clusterKey]: set }
}
async function doMerge(clusterKey: string): Promise<void> {
const target = mergeTarget[clusterKey]
const sources = [...(mergeSources[clusterKey] ?? [])]
if (!target || sources.length === 0) return
merging = clusterKey
mergeError = ''
try {
const result = await mergeKnowledge(target, sources)
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
onChanged()
await loadDuplicates()
} catch (e) {
mergeError = errMsg(e)
} finally {
merging = null
}
}
// ─── Tags ─────────────────────────────────────────────────────────────
let tags = $state<KnowledgeTag[] | null>(null)
let tagsError = $state('')
let renaming = $state<string | null>(null)
let renameDraft = $state('')
let renameBusy = $state(false)
async function loadTags(): Promise<void> {
tags = null
tagsError = ''
try {
tags = await fetchKnowledgeTags()
} catch (e) {
tagsError = errMsg(e)
tags = []
}
}
async function normalize(t: KnowledgeTag): Promise<void> {
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, t.tag)
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
} catch (e) {
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
function startRename(t: KnowledgeTag): void {
renaming = t.tag
renameDraft = t.tag
}
async function confirmRename(t: KnowledgeTag): Promise<void> {
const to = renameDraft.trim().toLowerCase()
if (!to || to === t.tag) {
renaming = null
return
}
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, to)
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
renaming = null
} catch (e) {
// Leave the rename input open on failure — the operator's typed value
// (and their reason for changing it) shouldn't vanish along with the
// error, forcing them to retype it to try again.
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
// ─── Orphans ──────────────────────────────────────────────────────────
let orphans = $state<KnowledgeOrphan[] | null>(null)
let orphanCounts = $state<Record<string, number>>({})
let orphansError = $state('')
async function loadOrphans(): Promise<void> {
orphans = null
orphansError = ''
try {
const result = await fetchKnowledgeOrphans()
orphans = result.items
orphanCounts = result.counts
} catch (e) {
orphansError = errMsg(e)
orphans = []
}
}
// ─── Trash ────────────────────────────────────────────────────────────
let trash = $state<KnowledgeTrashItem[] | null>(null)
let trashError = $state('')
let restoring = $state<string | null>(null)
async function loadTrash(): Promise<void> {
trash = null
trashError = ''
try {
trash = await fetchKnowledgeTrash()
} catch (e) {
trashError = errMsg(e)
trash = []
}
}
async function doRestore(slug: string): Promise<void> {
restoring = slug
try {
await restoreKnowledge(slug)
toast.success('Note restored')
onChanged()
await loadTrash()
} catch (e) {
toast.error(errMsg(e))
} finally {
restoring = null
}
}
function activate(t: typeof tab): void {
tab = t
if (t === 'duplicates' && clusters === null) loadDuplicates()
else if (t === 'tags' && tags === null) loadTags()
else if (t === 'orphans' && orphans === null) loadOrphans()
else if (t === 'trash' && trash === null) loadTrash()
}
// Initial tab's data.
loadDuplicates()
</script>
<div class="flex h-full flex-col gap-2">
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-8 w-fit">
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
<CopyIcon class="size-3.5" /> Duplicates
</Tabs.Trigger>
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
<TagIcon class="size-3.5" /> Tags
</Tabs.Trigger>
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
<GhostIcon class="size-3.5" /> Orphans
</Tabs.Trigger>
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
<Trash2Icon class="size-3.5" /> Trash
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
</p>
{#if mergeError}
<p
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{mergeError}
</p>
{/if}
{#if duplicatesError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{duplicatesError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
>Retry</Button
>
</p>
{/if}
{#if clusters === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if clusters.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
{:else}
<div class="flex flex-col gap-3">
{#each clusters as c (c.members[0].slug)}
{@const key = c.members[0].slug}
{@const sourceCount = mergeSources[key]?.size ?? 0}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 text-xs">
<span class="font-medium">{c.members.length} similar notes</span>
<!-- Similarity as a meter rather than only a number: it's a
ratio, and the bar makes a 93% pileup visibly different
from a borderline 61% at a glance down a long list. -->
<span
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
>
<span
class="block h-full rounded-full bg-primary"
style="width: {c.top_similarity * 100}%"
></span>
</span>
<span class="shrink-0 tabular-nums text-muted-foreground"
>{(c.top_similarity * 100).toFixed(0)}%</span
>
</div>
<Button
size="sm"
class="h-6 shrink-0 gap-1 text-xs"
disabled={merging === key || sourceCount === 0}
onclick={() => doMerge(key)}
>
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
</Button>
</div>
<!-- Two bare inputs per row read as "…what do these do?", so
name them once per cluster. An inline legend rather than
column headers: the controls are 14px wide and the words
are not, so headers sized to the columns just collide. -->
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> keep as target
</span>
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> fold into it
</span>
</p>
<div class="flex flex-col p-1">
{#each c.members as m (m.slug)}
{@const isTarget = mergeTarget[key] === m.slug}
{@const Icon = kindMeta(m.kind).icon}
<label
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
? 'bg-primary/5'
: 'hover:bg-muted/40'}"
>
<input
type="radio"
class="size-3.5 shrink-0 accent-[var(--primary)]"
name="target-{key}"
aria-label="Keep &quot;{m.title}&quot; as the merge target"
checked={isTarget}
onchange={() =>
setMergeTarget(
key,
m.slug,
c.members.map((mm) => mm.slug)
)}
/>
<input
type="checkbox"
class="size-3.5 shrink-0 accent-[var(--primary)]"
aria-label="Fold &quot;{m.title}&quot; into the target"
disabled={isTarget}
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
onchange={() => toggleSource(key, m.slug)}
/>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<button
type="button"
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
? 'font-medium text-primary'
: ''}"
onclick={() => onSelect(m.slug)}
>
{m.title}
</button>
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(m.updated_at)}</span
>
{#if isTarget}
<Badge
variant="outline"
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if tagsError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{tagsError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
</p>
{/if}
{#if tags === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else}
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each tags as t (t.tag)}
<tr class="border-b border-border/50">
<td class="py-1 pr-2">
{#if renaming === t.tag}
<div class="flex items-center gap-1">
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
<Button
size="sm"
class="h-6 px-1.5"
disabled={renameBusy}
onclick={() => confirmRename(t)}
>
<CheckIcon class="size-3" />
</Button>
</div>
{:else}
<button
type="button"
class="font-mono hover:underline"
onclick={() => startRename(t)}
>
{t.tag}
</button>
{/if}
</td>
<!-- tabular-nums here (unlike the overview's standalone
figures): these are a column that has to line up. -->
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
<td class="w-24 py-1 pr-3">
<!-- Magnitude, so: one hue, length-encoded, scaled to the
most-used tag. Recessive by design — it's a reading aid
down the column, not the subject of the table. -->
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
<span
class="block h-full rounded-full bg-primary/60"
style="width: {(t.uses / maxUses) * 100}%"
></span>
</span>
</td>
<td class="py-1 pr-2">
{#if t.split}
<span class="text-destructive">{t.variants.join(', ')}</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
<td class="py-1 text-right">
{#if t.split}
<Button
size="sm"
variant="outline"
class="h-6 text-xs"
disabled={renameBusy}
onclick={() => normalize(t)}
>
Normalize
</Button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Tabs.Content>
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
navigation paths and easy to lose track of.
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
0} stale)
{/if}
</p>
{#if orphansError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{orphansError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
>Retry</Button
>
</p>
{/if}
{#if orphans === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if orphans.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each orphans as o (o.slug)}
<button
type="button"
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
onclick={() => onSelect(o.slug)}
>
<span class="min-w-0 flex-1 truncate">{o.title}</span>
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
>{r}</Badge
>{/each}
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(o.updated_at)}</span
>
</button>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if trashError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{trashError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
</p>
{/if}
{#if trash === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if trash.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each trash as t (t.slug)}
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
<span class="min-w-0 flex-1 truncate">{t.title}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
</span>
<Button
size="sm"
variant="outline"
class="h-6 shrink-0 gap-1 text-xs"
disabled={restoring === t.slug}
onclick={() => doRestore(t.slug)}
>
<RotateCcwIcon class="size-3" /> Restore
</Button>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
// Right pane: the discovery half of the wiki. From any note you can walk
// to the entity it's about, and from there sideways to every other note
// that concerns the same entity or shares a tag — this is what makes the
// knowledge base a graph to browse rather than a flat list to scroll.
//
// "Related" and "tag neighbours" are derived client-side from the list
// already loaded by Knowledge.svelte (KnowledgeListItem carries `about`
// and `tags`), not a separate endpoint — with ~100 notes total, filtering
// an in-memory array is cheaper and simpler than a bespoke backlinks
// query, and it's exactly the same data WikiTree's group-by-entity/tag
// modes already use.
import type { KnowledgeListItem } from '$lib/api'
import { openEntityWindow } from '$lib/stores/windows'
import DetailSection from '$lib/components/DetailSection.svelte'
import { kindMeta } from './kinds'
import LinkIcon from '@lucide/svelte/icons/link'
let {
item,
allItems,
onSelect
}: {
item: KnowledgeListItem | null
allItems: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
const related = $derived.by(() => {
if (!item || item.about.length === 0) return []
const aboutSet = new Set(item.about)
return allItems
.filter((it) => it.slug !== item.slug && it.about.some((s) => aboutSet.has(s)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
})
const tagNeighbours = $derived.by(() => {
if (!item || item.tags.length === 0) return []
const tagSet = new Set(item.tags)
return allItems
.filter((it) => it.slug !== item.slug && it.tags.some((t) => tagSet.has(t)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
.slice(0, 20) // common tags (e.g. "backup") can otherwise pull in most of the KB
})
</script>
<div class="flex h-full flex-col gap-2 overflow-y-auto pr-1">
{#if !item}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing selected.</p>
{:else}
<DetailSection title="About" count={item.about.length} defaultOpen={true}>
{#if item.about.length === 0}
<p class="text-xs text-muted-foreground">Not linked to any entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each item.about as slug (slug)}
<button
type="button"
class="flex items-center gap-1.5 rounded px-1 py-0.5 text-left font-mono text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
onclick={() => openEntityWindow(slug)}
>
<LinkIcon class="size-3 shrink-0" />
<span class="truncate">{slug}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<DetailSection
title="Also about these entities"
count={related.length}
defaultOpen={related.length > 0}
>
{#if related.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a linked entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each related as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<!-- Only auto-open a *tight* neighbour set. A generic tag like "container"
is on 20 notes, and expanding all of those by default buries the
stronger entity-based links above it under a wall of weak matches;
a handful of shared-tag notes is a real cluster worth surfacing. -->
<DetailSection
title="Tag neighbours"
count={tagNeighbours.length}
defaultOpen={related.length === 0 && tagNeighbours.length > 0 && tagNeighbours.length <= 6}
>
{#if tagNeighbours.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a tag.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each tagNeighbours as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
{/if}
</div>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
// "New note" dialog — the create half of the wiki. A plain toggle group for
// kind (document/investigation/runbook) rather than the Select primitive:
// three fixed, always-visible options don't need a popover, and this
// mirrors the same toggle-group pattern WikiTree already uses for its
// group-by switch.
import { createKnowledge, KnowledgeApiError } from '$lib/api'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
let {
open = $bindable(false),
onCreated
}: {
open: boolean
onCreated: (slug: string) => void
} = $props()
const KINDS = ['document', 'investigation', 'runbook'] as const
type Kind = (typeof KINDS)[number]
let title = $state('')
let kind = $state<Kind>('document')
let folder = $state('')
let tags = $state('')
let content = $state('')
let saving = $state(false)
let error = $state('')
function reset(): void {
title = ''
kind = 'document'
folder = ''
tags = ''
content = ''
error = ''
}
async function submit(): Promise<void> {
if (!title.trim() || !content.trim()) {
error = 'Title and content are required.'
return
}
saving = true
error = ''
try {
const result = await createKnowledge({
title: title.trim(),
content: content.trim(),
kind,
folder: folder.trim() || undefined,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
onCreated(result.slug)
open = false
reset()
} catch (e) {
error =
e instanceof KnowledgeApiError
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
: 'Create failed.'
} finally {
saving = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>New knowledge note</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-3">
{#if error}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{error}
</p>
{/if}
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-title">
Title
<Input id="new-note-title" bind:value={title} placeholder="Short, specific, searchable" />
</label>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Type</span>
<div class="inline-flex overflow-hidden rounded-md border">
{#each KINDS as k (k)}
<button
type="button"
class="px-2 py-1 text-xs {kind === k
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (kind = k)}
>
{k}
</button>
{/each}
</div>
</div>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-folder">
Folder <span class="text-muted-foreground/70">(optional — defaults to "operator")</span>
<Input
id="new-note-folder"
bind:value={folder}
placeholder="e.g. containers, infrastructure"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-tags">
Tags <span class="text-muted-foreground/70">(comma-separated, optional)</span>
<Input id="new-note-tags" bind:value={tags} placeholder="oom, rclone, gotcha" />
</label>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-content">
Content (markdown)
<Textarea
id="new-note-content"
bind:value={content}
class="min-h-[160px] font-mono text-xs"
/>
</label>
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)} disabled={saving}>Cancel</Button>
<Button onclick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
// The reader pane's resting state — what you see every time the app opens
// and nothing is selected yet.
//
// This used to be the sentence "Select a note, or create a new one."
// centred in an otherwise empty 56%-width pane: the single most-seen screen
// in the app doing no work at all. It's now the landing view, and it also
// restores the collection-level numbers the wiki redesign dropped (the old
// stats-only Knowledge page led with them, and they were the one thing that
// page did well — "the system is getting smarter" is only visible in
// aggregate).
//
// Every figure is derived from the `items` array the parent already loaded
// for the tree, so this panel costs no extra request.
import type { KnowledgeListItem } from '$lib/api'
import { kindMeta, isAgentAuthored } from './kinds'
import { relativeTime } from '$lib/utils'
import BotIcon from '@lucide/svelte/icons/bot'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import ClockIcon from '@lucide/svelte/icons/clock'
import HashIcon from '@lucide/svelte/icons/hash'
import PlusIcon from '@lucide/svelte/icons/plus'
import { Button } from '$lib/components/ui/button'
let {
items,
onSelect,
onNew
}: {
items: KnowledgeListItem[]
onSelect: (slug: string) => void
onNew: () => void
} = $props()
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
// Postgres renders timestamptz as "2026-07-26 10:50:53.475644+00" — a space
// instead of ISO-8601's 'T', and a bare two-digit offset. V8 happens to
// accept that verbatim, but Safari's parser requires the 'T' AND an offset
// of 'Z' or ±HH:MM, so both have to be normalised together: swapping only
// the separator yields "…475644+00", which is invalid ISO and parses to NaN
// *everywhere* — strictly worse than leaving the string alone.
function parseTimestamp(raw: string): number {
return Date.parse(raw.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00'))
}
const stats = $derived.by(() => {
const now = Date.now()
let agent = 0
let lastWeek = 0
const byKind = new Map<string, number>()
const tagCounts = new Map<string, number>()
for (const it of items) {
if (isAgentAuthored(it.edited_by)) agent++
const ts = parseTimestamp(it.updated_at)
if (!Number.isNaN(ts) && now - ts < WEEK_MS) lastWeek++
byKind.set(it.kind, (byKind.get(it.kind) ?? 0) + 1)
for (const t of it.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1)
}
return {
total: items.length,
agent,
lastWeek,
byKind,
topTags: [...tagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
}
})
// Share of the collection the agent wrote — the "is this thing actually
// learning" number, and the only ratio here worth a meter rather than
// another tile.
const agentShare = $derived(stats.total === 0 ? 0 : Math.round((stats.agent / stats.total) * 100))
const recent = $derived(
[...items].sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, 6)
)
// Kinds in a fixed order so the row doesn't reshuffle as counts change.
const KIND_ORDER = ['runbook', 'investigation', 'document'] as const
</script>
<div class="mx-auto flex h-full w-full max-w-2xl flex-col gap-7 overflow-y-auto px-1 py-6">
{#if stats.total === 0}
<!-- Genuinely empty collection (not a failed load — the parent handles
that case before rendering this component). -->
<div class="flex flex-1 flex-col items-center justify-center gap-3 text-center">
<h2 class="text-lg font-semibold">Nothing here yet</h2>
<p class="max-w-sm text-sm text-muted-foreground">
The knowledge base is empty. Write the first note, or let Nomos record what it learns as it
works.
</p>
<Button size="sm" class="gap-1.5" onclick={onNew}>
<PlusIcon class="size-3.5" /> New note
</Button>
</div>
{:else}
<!-- Hero: the one number the view leads with. Sans, not the Inknut
heading face — a serif at display size reads as decoration rather
than data. Proportional figures (no tabular-nums): this is a
standalone value, not a column that has to align. -->
<div>
<h2 class="text-sm font-medium tracking-wide text-muted-foreground uppercase">
Knowledge base
</h2>
<div class="mt-1 flex items-baseline gap-2.5">
<span class="font-sans text-5xl leading-none font-semibold">{stats.total}</span>
<span class="text-sm text-muted-foreground">notes</span>
</div>
</div>
<!-- KPI row. Hairline dividers rather than boxed cards: at four items the
boxes were doing more visual work than the numbers inside them. -->
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border/60 sm:grid-cols-4">
{#each KIND_ORDER as k (k)}
{@const meta = kindMeta(k)}
{@const Icon = meta.icon}
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Icon class="size-3.5" />
{meta.plural}
</span>
<span class="text-xl font-semibold">{stats.byKind.get(k) ?? 0}</span>
</div>
{/each}
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<SparklesIcon class="size-3.5" /> this week
</span>
<span class="text-xl font-semibold">{stats.lastWeek}</span>
</div>
</div>
<!-- Meter: one ratio, one hue. Track is a lighter step of the fill's own
ramp so the whole bar reads as a single scale. -->
<div class="flex flex-col gap-1.5">
<div class="flex items-baseline justify-between text-xs">
<span class="flex items-center gap-1.5 text-muted-foreground">
<BotIcon class="size-3.5" /> Written by Nomos
</span>
<span class="text-muted-foreground">
<span class="font-semibold text-foreground">{stats.agent}</span> of {stats.total} · {agentShare}%
</span>
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-primary/15"
role="meter"
aria-valuenow={agentShare}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Share of notes written by Nomos"
>
<div class="h-full rounded-full bg-primary" style="width: {agentShare}%"></div>
</div>
</div>
<div class="flex flex-col gap-2">
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<ClockIcon class="size-3.5" /> Recently updated
</h3>
<div class="flex flex-col">
{#each recent as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
>
<Icon class="size-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm group-hover:text-primary">{it.title}</span>
{#if isAgentAuthored(it.edited_by)}
<BotIcon class="size-3 shrink-0 text-muted-foreground" />
{/if}
<span class="shrink-0 text-[11px] text-muted-foreground"
>{relativeTime(it.updated_at)}</span
>
</button>
{/each}
</div>
</div>
{#if stats.topTags.length > 0}
<div class="flex flex-col gap-2">
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<HashIcon class="size-3.5" /> Busiest tags
</h3>
<div class="flex flex-wrap gap-1.5">
{#each stats.topTags as [tag, count] (tag)}
<span
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{tag}
<span class="text-foreground/70 tabular-nums">{count}</span>
</span>
{/each}
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
// Cmd/Ctrl+K quick-open over every note title — the fast path once you
// already know roughly what you're looking for, as opposed to WikiTree's
// browse-by-group path for when you don't. Built on the Dialog primitive
// + a plain filtered list rather than shadcn-svelte's `command` component:
// that component's interactive CLI installer couldn't be driven
// non-interactively in this environment (it prompts to resolve overlapping
// dependency files), and re-deriving the same arrow-key/Enter list nav by
// hand here is a small amount of code for something this self-contained.
import type { KnowledgeListItem } from '$lib/api'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input'
import StatusBadge from '$lib/components/StatusBadge.svelte'
import { onDestroy } from 'svelte'
let {
open = $bindable(false),
items,
onSelect
}: {
open: boolean
items: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
let query = $state('')
let activeIndex = $state(0)
let inputEl = $state<HTMLInputElement | null>(null)
const results = $derived.by(() => {
const q = query.trim().toLowerCase()
const pool = q
? items.filter(
(it) =>
it.title.toLowerCase().includes(q) ||
it.slug.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
: items
return pool.slice(0, 30) // 102 notes total — cap the render, not the match
})
$effect(() => {
void results // dependency only — re-run when the result set changes
activeIndex = 0
})
// Reset on every open so quick-open never remembers the last search, and
// focus the input once the dialog has actually mounted it.
$effect(() => {
if (open) {
query = ''
queueMicrotask(() => inputEl?.focus())
}
})
function choose(slug: string): void {
onSelect(slug)
open = false
}
function handleKeydown(e: KeyboardEvent): void {
if (!open) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex = Math.min(activeIndex + 1, results.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex = Math.max(activeIndex - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
const hit = results[activeIndex]
if (hit) choose(hit.slug)
}
}
// A window-level listener rather than one on Dialog.Content: bits-ui's
// Dialog renders its content through a portal with its own focus-trap
// wiring, and an onkeydown prop passed straight through to Content did not
// reliably receive ArrowDown/Enter in testing (focus landing inside the
// trap didn't guarantee the event reached the element this component
// attached the listener to). Capturing at the window and gating on `open`
// sidesteps that entirely — Escape-to-close is still bits-ui's own
// behavior, this only adds the list-navigation keys.
window.addEventListener('keydown', handleKeydown)
onDestroy(() => window.removeEventListener('keydown', handleKeydown))
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="top-[20%] max-w-lg -translate-y-0 gap-0 p-0 sm:max-w-lg"
showCloseButton={false}
>
<Input
bind:ref={inputEl}
bind:value={query}
placeholder="Jump to a note…"
class="h-11 rounded-b-none border-0 border-b px-3 text-sm focus-visible:ring-0"
/>
<div class="max-h-80 overflow-y-auto p-1">
{#each results as it, i (it.slug)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm {i ===
activeIndex
? 'bg-primary/10 text-primary'
: 'hover:bg-muted/50'}"
onclick={() => choose(it.slug)}
onmouseenter={() => (activeIndex = i)}
>
<span class="min-w-0 flex-1 truncate">{it.title}</span>
<StatusBadge kind="type" value={it.kind} class="shrink-0 text-[9px]" />
</button>
{:else}
<p class="py-6 text-center text-xs text-muted-foreground">No notes match "{query}".</p>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,445 @@
<script lang="ts">
// Center pane: read a note, edit it in place, or browse its history.
//
// `item` carries the list-derived metadata (kind, tags, about, edited_by —
// everything WikiTree already has); the full body is fetched here lazily
// per selection, same split as the API (serveKnowledgeList never returns
// content — see knowledge_write.go — so the tree stays cheap and only the
// note actually being read pays for its body).
import {
fetchKnowledgeContent,
fetchKnowledgeRevisions,
updateKnowledge,
deleteKnowledge,
KnowledgeApiError,
type KnowledgeListItem,
type KnowledgeContent,
type KnowledgeRevision
} from '$lib/api'
import { renderWikiMarkdown, slugFromKbHref, diffLines } from './wikiText'
import { kindMeta, isAgentAuthored } from './kinds'
import WikiOverview from './WikiOverview.svelte'
import { openEntityWindow } from '$lib/stores/windows'
import { relativeTime } from '$lib/utils'
import { toast } from 'svelte-sonner'
import * as Tabs from '$lib/components/ui/tabs'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
import Spinner from '$lib/components/Spinner.svelte'
import PencilIcon from '@lucide/svelte/icons/pencil'
import TrashIcon from '@lucide/svelte/icons/trash-2'
import HistoryIcon from '@lucide/svelte/icons/history'
import BotIcon from '@lucide/svelte/icons/bot'
import XIcon from '@lucide/svelte/icons/x'
import SaveIcon from '@lucide/svelte/icons/save'
let {
item,
allItems,
knownSlugs,
onNavigate,
onNew,
onChanged,
dirty = $bindable(false)
}: {
item: KnowledgeListItem | null
// The whole collection — only used for the resting-state overview shown
// when nothing is selected (WikiOverview derives its figures from it).
allItems: KnowledgeListItem[]
knownSlugs: Set<string>
onNavigate: (slug: string) => void
onNew: () => void
// Fired after a save or delete that the parent's cached list needs to
// reflect (title/tags changed, or the note is gone). Parent decides
// whether to refetch the whole list or patch locally.
onChanged: () => void
// True while there's an in-progress edit that would be silently
// discarded if `item` changed out from under this component. Knowledge.svelte
// reads this before switching the selection (tree click, quick-open,
// etc.) so it can confirm with the operator first — see its
// requestSelect. Deliberately "in edit mode" rather than a real dirty
// diff against the loaded content: simpler, and erring toward "ask
// even if nothing actually changed" is the safe direction for a
// destructive-by-default operation.
dirty?: boolean
} = $props()
let content = $state<KnowledgeContent | null>(null)
let loading = $state(false)
let mode = $state<'read' | 'edit'>('read')
let tab = $state<'note' | 'history'>('note')
let saveError = $state('')
let saving = $state(false)
let draftTitle = $state('')
let draftContent = $state('')
let draftTags = $state('')
let draftAbout = $state('')
let revisions = $state<KnowledgeRevision[] | null>(null)
let revisionsLoading = $state(false)
let selectedRevisionId = $state<number | null>(null)
async function load(slug: string): Promise<void> {
loading = true
mode = 'read'
dirty = false
tab = 'note'
revisions = null
selectedRevisionId = null
saveError = ''
content = await fetchKnowledgeContent(slug)
loading = false
}
$effect(() => {
if (item) load(item.slug)
else content = null
})
function startEdit(): void {
if (!content || !item) return
draftTitle = content.title
draftContent = content.content
draftTags = content.tags.join(', ')
draftAbout = item.about.join(', ')
saveError = ''
mode = 'edit'
dirty = true
}
function cancelEdit(): void {
mode = 'read'
dirty = false
saveError = ''
}
async function save(): Promise<void> {
if (!item) return
const title = draftTitle.trim()
const body = draftContent.trim()
if (!title || !body) {
saveError = 'Title and content cannot be empty.'
return
}
saving = true
saveError = ''
const about = draftAbout
.split(',')
.map((s) => s.trim())
.filter(Boolean)
try {
const result = await updateKnowledge(item.slug, {
title,
content: body,
tags: draftTags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
about
})
// A typo'd entity slug in "About" fails to link server-side with only
// a log line (see linkKnowledgeAbout) — diff what came back against
// what was submitted so that doesn't happen silently.
const unresolved = about.filter((s) => !result.linked?.includes(s))
if (unresolved.length > 0) {
toast.error(`Couldn't link to: ${unresolved.join(', ')} — check the slug is correct.`)
}
mode = 'read'
dirty = false
await load(item.slug)
onChanged()
} catch (e) {
saveError =
e instanceof KnowledgeApiError
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
: 'Save failed.'
} finally {
saving = false
}
}
let confirmDeleteOpen = $state(false)
let deleting = $state(false)
// Soft delete (see migrations/022_knowledge_revisions.up.sql) — the note
// goes to trash and can be brought back, so this is a lightweight confirm
// rather than anything heavier. It's an in-app Dialog rather than the
// browser's native confirm(): this app runs inside a custom floating
// window (its own desktop-shell chrome), and a native confirm() blocks
// the entire page's JS event loop until dismissed — in testing that froze
// the tab hard enough that automated clicks stopped registering
// entirely. A real dialog stays inside Svelte's event handling and can't
// wedge the app that way.
async function confirmDelete(): Promise<void> {
if (!item) return
deleting = true
try {
await deleteKnowledge(item.slug)
confirmDeleteOpen = false
onChanged()
} catch (e) {
saveError = e instanceof KnowledgeApiError ? e.message : 'Delete failed.'
} finally {
deleting = false
}
}
async function openHistory(): Promise<void> {
tab = 'history'
if (revisions !== null || !item) return
revisionsLoading = true
revisions = await fetchKnowledgeRevisions(item.slug)
selectedRevisionId = revisions[0]?.id ?? null
revisionsLoading = false
}
// Bare slugs are auto-linked (see wikiText.ts) as `#kb:<slug>` anchors.
// Intercepted here via event delegation on the rendered container — the
// markdown body is injected with {@html}, so component-level click
// bindings can't attach to individual links, but a plain bubbling
// listener on the wrapper works the same as it would for real DOM.
function handleContentClick(e: MouseEvent): void {
const anchor = (e.target as HTMLElement).closest('a')
if (!anchor) return
const slug = slugFromKbHref(anchor.getAttribute('href'))
if (!slug) return
e.preventDefault()
if (knownSlugs.has(slug)) onNavigate(slug)
else openEntityWindow(slug)
}
const selectedRevision = $derived(revisions?.find((r) => r.id === selectedRevisionId) ?? null)
// Diff against the CURRENT live body, not the next revision — the History
// tab answers "what did this look like before it became what it is now,"
// not "what changed between two arbitrary edits."
const diff = $derived(
selectedRevision && content ? diffLines(selectedRevision.content, content.content) : null
)
</script>
<div class="flex h-full min-w-0 flex-col gap-2">
{#if !item}
<WikiOverview items={allItems} onSelect={onNavigate} {onNew} />
{:else if loading}
<div class="flex h-full items-center justify-center"><Spinner /></div>
{:else if !content}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Couldn't load this note.
</div>
{:else}
<!-- Header: kind + title, then a single provenance line. Previously these
were one wrapping row of badges and text fragments; splitting
"what this is" from "where it came from" stops the title competing
with its own metadata. -->
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
<div class="min-w-0 flex-1">
{#if mode === 'edit'}
<Input bind:value={draftTitle} class="mb-1 h-8 font-medium" placeholder="Title" />
{:else}
{@const KindIcon = kindMeta(item.kind).icon}
<div class="flex min-w-0 items-center gap-2">
<KindIcon class="size-4 shrink-0 text-muted-foreground" />
<h2 class="truncate text-base font-semibold">{content.title}</h2>
</div>
{/if}
<div
class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"
>
<span class="capitalize">{kindMeta(item.kind).label}</span>
<span aria-hidden="true">·</span>
{#if isAgentAuthored(content.edited_by)}
<span class="flex items-center gap-1 text-primary">
<BotIcon class="size-3" /> Nomos
</span>
{:else if content.edited_by}
<span>{content.edited_by}</span>
{:else}
<span>unknown author</span>
{/if}
<span aria-hidden="true">·</span>
<span>updated {relativeTime(content.updated_at)}</span>
{#if content.revisions > 0}
<span aria-hidden="true">·</span>
<button
type="button"
class="underline decoration-dotted underline-offset-2 hover:text-foreground"
onclick={openHistory}
>
{content.revisions} revision{content.revisions === 1 ? '' : 's'}
</button>
{/if}
</div>
</div>
<div class="flex shrink-0 gap-1">
{#if mode === 'read'}
<Button size="sm" variant="outline" class="h-7 gap-1 text-xs" onclick={startEdit}>
<PencilIcon class="size-3.5" /> Edit
</Button>
<Button
size="sm"
variant="ghost"
class="h-7 gap-1 text-xs text-destructive"
onclick={() => (confirmDeleteOpen = true)}
>
<TrashIcon class="size-3.5" />
</Button>
{:else}
<Button
size="sm"
variant="ghost"
class="h-7 gap-1 text-xs"
onclick={cancelEdit}
disabled={saving}
>
<XIcon class="size-3.5" /> Cancel
</Button>
<Button size="sm" class="h-7 gap-1 text-xs" onclick={save} disabled={saving}>
<SaveIcon class="size-3.5" />
{saving ? 'Saving…' : 'Save'}
</Button>
{/if}
</div>
</div>
{#if saveError}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{saveError}
</p>
{/if}
{#if mode === 'edit'}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<Textarea
bind:value={draftContent}
class="min-h-[240px] flex-1 resize-none font-mono text-xs"
placeholder="Markdown content…"
/>
<label class="text-xs text-muted-foreground" for="wiki-tags">
Tags (comma-separated)
<Input
id="wiki-tags"
bind:value={draftTags}
class="mt-1 h-7 text-xs"
placeholder="oom, rclone, gotcha"
/>
</label>
<label class="text-xs text-muted-foreground" for="wiki-about">
About (entity slugs, comma-separated)
<Input
id="wiki-about"
bind:value={draftAbout}
class="mt-1 h-7 text-xs"
placeholder="host:strong, lxc:gitea"
/>
</label>
</div>
{:else}
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-7 w-fit">
<Tabs.Trigger value="note" class="text-xs">Note</Tabs.Trigger>
<Tabs.Trigger value="history" class="gap-1 text-xs" onclick={openHistory}>
<HistoryIcon class="size-3" /> History
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="note" class="min-h-0 flex-1 overflow-y-auto pt-3">
{#if item.tags.length}
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
{#each item.tags as t (t)}<span
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
>{t}</span
>{/each}
</div>
{/if}
<!-- event delegation over rendered markdown: the interactive elements are the <a>
tags inside, already keyboard-operable on their own. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- max-w-[68ch]: without a measure the body ran the full width of a
resizable pane, which at a wide split is well past the ~75ch
where prose stops being comfortable to read. -->
<div
class="markdown-body max-w-[68ch] text-sm leading-relaxed"
onclick={handleContentClick}
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify in renderWikiMarkdown -->
{@html renderWikiMarkdown(content.content)}
</div>
</Tabs.Content>
<Tabs.Content value="history" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if revisionsLoading}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if !revisions || revisions.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">
No prior revisions — this is the first version.
</p>
{:else}
<div class="flex gap-3">
<div class="flex w-40 shrink-0 flex-col gap-0.5">
{#each revisions as rev (rev.id)}
<button
type="button"
class="rounded px-2 py-1 text-left text-[11px] hover:bg-muted/50 {selectedRevisionId ===
rev.id
? 'bg-primary/10 text-primary'
: ''}"
onclick={() => (selectedRevisionId = rev.id)}
>
<div class="font-medium">{relativeTime(rev.version_at)}</div>
<div class="text-muted-foreground">{rev.edited_by || 'unknown'}</div>
</button>
{/each}
</div>
<div class="min-w-0 flex-1 overflow-x-auto rounded border">
{#if diff}
<pre class="p-2 text-[11px] leading-relaxed">{#each diff as op, i (i)}<div
class={op.type === 'add'
? 'bg-success/10 text-success'
: op.type === 'remove'
? 'bg-destructive/10 text-destructive line-through'
: ''}>{op.type === 'add'
? '+ '
: op.type === 'remove'
? '- '
: ' '}{op.line}</div>{/each}</pre>
{/if}
</div>
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
{/if}
{/if}
</div>
<Dialog.Root bind:open={confirmDeleteOpen}>
<Dialog.Content class="sm:max-w-sm">
<Dialog.Header>
<Dialog.Title>Delete note?</Dialog.Title>
<Dialog.Description>
{#if item}"{item.title}" will move to Trash and can be restored from there.{/if}
</Dialog.Description>
</Dialog.Header>
{#if saveError}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{saveError}
</p>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (confirmDeleteOpen = false)} disabled={deleting}
>Cancel</Button
>
<Button variant="destructive" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
// Left pane of the Knowledge wiki: a tree over every live note, with a
// grouping switch so the same 102 notes are reachable four different
// ways — which one helps depends on what the operator already remembers
// about the thing they're looking for (its topic, its type, a tag, or the
// machine it concerns).
import type { KnowledgeListItem } from '$lib/api'
import { groupNotes, type GroupBy } from './wikiText'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import * as Collapsible from '$lib/components/ui/collapsible'
import { Button } from '$lib/components/ui/button'
import { kindMeta, isAgentAuthored } from './kinds'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SearchIcon from '@lucide/svelte/icons/search'
import PlusIcon from '@lucide/svelte/icons/plus'
import BotIcon from '@lucide/svelte/icons/bot'
import FolderTreeIcon from '@lucide/svelte/icons/folder-tree'
let {
items,
selectedSlug,
onSelect,
onNew
}: {
items: KnowledgeListItem[]
selectedSlug: string | null
onSelect: (slug: string) => void
onNew: () => void
} = $props()
const GROUP_LABELS: Record<GroupBy, string> = {
folder: 'Folder',
kind: 'Type',
tag: 'Tag',
entity: 'Entity'
}
function loadGroupBy(): GroupBy {
if (typeof localStorage === 'undefined') return 'folder'
const v = localStorage.getItem('oikos-wiki-groupby')
return v === 'kind' || v === 'tag' || v === 'entity' ? v : 'folder'
}
let groupBy = $state<GroupBy>(loadGroupBy())
let filter = $state('')
function setGroupBy(v: string): void {
if (v !== 'folder' && v !== 'kind' && v !== 'tag' && v !== 'entity') return
groupBy = v
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-wiki-groupby', v)
}
const filtered = $derived.by(() => {
const q = filter.trim().toLowerCase()
if (!q) return items
return items.filter(
(it) =>
it.title.toLowerCase().includes(q) ||
it.slug.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
})
const groups = $derived(groupNotes(filtered, groupBy))
// Every group starts open when the filter is active (so a match is never
// hidden inside a collapsed group) and only the group containing the
// current selection starts open otherwise — with 102 notes across ~15
// folders, all-open-by-default would just be a long undifferentiated
// scroll.
let openGroups = $state<Set<string>>(new Set())
$effect(() => {
if (filter.trim()) {
openGroups = new Set(groups.map((g) => g.key))
return
}
const owning = groups.find((g) => g.items.some((it) => it.slug === selectedSlug))
openGroups = new Set(owning ? [owning.key] : groups[0] ? [groups[0].key] : [])
})
function toggleGroup(key: string): void {
const next = new Set(openGroups)
if (next.has(key)) next.delete(key)
else next.add(key)
openGroups = next
}
</script>
<div class="flex h-full flex-col gap-1.5">
<!-- Search and "new note" share a row: both act on the list as a whole,
and pairing them lets the field take the remaining width instead of
being squeezed by a fixed-width control beside it. -->
<div class="flex items-center gap-1.5">
<div class="relative flex-1">
<SearchIcon class="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Filter notes…" bind:value={filter} class="h-7 pl-7 text-xs" />
</div>
<Button
size="sm"
variant="outline"
class="size-7 shrink-0 p-0"
onclick={onNew}
title="New note"
aria-label="New note"
>
<PlusIcon class="size-3.5" />
</Button>
</div>
<!-- Group-by reads as a caption for the tree rather than a third boxed
input: it labels how the list below is arranged, so it's styled like
the group headers it governs (same muted 11px) and only reveals itself
as a control on hover. Its own row of chrome was competing with the
search field for attention while doing far less work. -->
<Select.Root type="single" value={groupBy} onValueChange={setGroupBy}>
<Select.Trigger
size="sm"
class="h-auto w-fit gap-1 rounded border-0 bg-transparent px-1 py-0.5 text-[11px] font-normal tracking-wide text-muted-foreground uppercase shadow-none hover:bg-muted/40 hover:text-foreground focus-visible:ring-0 data-[size=sm]:h-auto dark:bg-transparent dark:hover:bg-muted/40"
title="Change how notes are grouped"
>
<FolderTreeIcon class="size-3 opacity-70" />
by {GROUP_LABELS[groupBy]}
</Select.Trigger>
<Select.Content>
{#each Object.entries(GROUP_LABELS) as [key, label] (key)}
<Select.Item value={key} {label}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="-mx-1 min-h-0 flex-1 overflow-y-auto px-1">
{#each groups as group (group.key)}
{@const isOpen = openGroups.has(group.key)}
<Collapsible.Root open={isOpen} onOpenChange={() => toggleGroup(group.key)}>
<Collapsible.Trigger
class="group/grp flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left select-none hover:bg-muted/40"
>
<ChevronRightIcon
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150 {isOpen
? 'rotate-90'
: ''}"
/>
<span
class="min-w-0 flex-1 truncate text-[11px] font-medium tracking-wide text-muted-foreground uppercase group-hover/grp:text-foreground"
>{group.label}</span
>
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/70"
>{group.items.length}</span
>
</Collapsible.Trigger>
<Collapsible.Content>
<!-- The guide rule sits inside the indent rather than on each row so
it reads as one continuous line down the group. -->
<div class="mb-1 ml-[13px] flex flex-col border-l border-border/60 pl-1.5">
{#each group.items as it (it.slug + group.key)}
{@const selected = selectedSlug === it.slug}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
title={it.title}
class="relative flex items-center gap-2 rounded-md py-1.5 pr-1.5 pl-2 text-left text-xs transition-colors {selected
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-muted/50'}"
onclick={() => onSelect(it.slug)}
>
<!-- Selection also gets an accent bar on the guide rule: the
background tint alone is easy to lose against the window's
own surface at this size. -->
{#if selected}
<span
class="absolute top-1 bottom-1 -left-[7px] w-[2px] rounded-full bg-primary"
aria-hidden="true"
></span>
{/if}
<Icon
class="size-3.5 shrink-0 {selected ? 'text-primary' : 'text-muted-foreground/70'}"
/>
<span class="min-w-0 flex-1 truncate">{it.title}</span>
{#if isAgentAuthored(it.edited_by)}
<BotIcon
class="size-3 shrink-0 {selected
? 'text-primary/70'
: 'text-muted-foreground/50'}"
/>
{/if}
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<p class="px-2 py-8 text-center text-xs text-muted-foreground">
No notes match &ldquo;{filter}&rdquo;.
</p>
{/each}
</div>
</div>

View File

@@ -0,0 +1,50 @@
// Per-kind presentation, shared by the tree, the overview, and anywhere else
// a note's kind needs to be shown at a glance.
//
// Kind is encoded by **icon shape**, not colour. Three categories would be a
// categorical palette, and at the 12px mark size the tree uses, colour alone
// is the least reliable channel there is — it fails for colour-vision
// deficiency, and small low-chroma marks on a dark surface are hard for
// anyone to tell apart. Distinct silhouettes are legible at any size, in any
// theme, for every reader. Colour is left to carry state (selection, the
// agent badge), where it isn't the only thing distinguishing two items.
//
// It also fixes a plain redundancy: the tree previously stamped a literal
// "document" text badge on every row, which in a folder of 20 documents is
// 20 repetitions of the same word and no information at all.
import FileTextIcon from '@lucide/svelte/icons/file-text'
import MicroscopeIcon from '@lucide/svelte/icons/microscope'
import ListChecksIcon from '@lucide/svelte/icons/list-checks'
import type { Component } from 'svelte'
export type NoteKind = 'document' | 'investigation' | 'runbook'
export interface KindMeta {
icon: Component
label: string
/** Plural, for counts and section headings. */
plural: string
}
const FALLBACK: KindMeta = { icon: FileTextIcon, label: 'note', plural: 'notes' }
const KIND_META: Record<NoteKind, KindMeta> = {
document: { icon: FileTextIcon, label: 'document', plural: 'documents' },
investigation: { icon: MicroscopeIcon, label: 'investigation', plural: 'investigations' },
runbook: { icon: ListChecksIcon, label: 'runbook', plural: 'runbooks' }
}
// Tolerates an unknown kind rather than throwing — `kind` comes from the
// entity's type column, which the ontology could grow a fourth value for
// without this file knowing.
export function kindMeta(kind: string): KindMeta {
return KIND_META[kind as NoteKind] ?? FALLBACK
}
// True for notes last written by the agent rather than a human. Two spellings
// exist in the live data: 'nomos-agent' (written via the MCP upsert_knowledge
// tool) and 'agent:mcp' (the actor label the HTTP API records when the same
// agent calls in over REST with the MCP bearer token).
export function isAgentAuthored(editedBy: string): boolean {
return editedBy === 'nomos-agent' || editedBy === 'agent:mcp'
}

View File

@@ -0,0 +1,197 @@
// Shared text helpers for the knowledge wiki (Knowledge.svelte and its
// components). Markdown rendering, slug auto-linking, folder/grouping
// derivation, and a small line diff for the History view — split out from
// any one component since WikiReader and WikiContextRail both need the
// rendering/linking half, and WikiTree needs the grouping half.
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { KnowledgeListItem } from '$lib/api'
// Matches a bare entity/knowledge slug like `lxc:gitea` or
// `document:containers/101-jellyfin` — real examples pulled straight from
// the data (`grep`-confirmed: operators and Nomos both write bare slugs
// throughout note bodies today). The `[[wiki-link]]` bracket syntax some
// wikis use was considered and dropped: only one note in the live DB
// contains "[[" at all, and it's an HTML comment, not a link — building
// bracket-syntax parsing would add real complexity (nesting, alias syntax,
// double-substitution risk with this very regex) for a feature nobody
// writes.
//
// Anchored to start with a lowercase letter specifically to reject
// clock-times like "10:08" or "20:40" that are common in this dataset's
// investigation titles/bodies (digits don't match `[a-z]`) and to reject
// "https://..." (the char after ':' there is '/', which fails the
// alnum-first requirement on the right-hand side).
const SLUG_PATTERN = `\\b([a-z][a-z0-9-]{1,30}:[a-zA-Z0-9][a-zA-Z0-9\\-/._]*)\\b`
// A fenced code block (```...```, across lines) or an inline code span
// (`...`, single line) OR a bare slug — tried in that order at every
// position. Fenced/inline code always wins the match when present, so a
// slug-shaped token *inside* a code example (a runbook's shell snippet
// referencing e.g. `host:strong/some-path`) is consumed whole as code and
// never reaches the slug branch. Without this, linkifySlugs ran the slug
// regex over raw markdown with no idea code existed, rewrote the slug
// inside the span to `[slug](#kb:slug)`, and `marked` then rendered that
// literal bracket/paren syntax as text inside the <code> tag instead of
// treating it as code. Doesn't handle every markdown code-span edge case
// (double-backtick escaping for spans containing a literal backtick, `~~~`
// fences) — just the two forms actually used in this corpus.
const TOKEN_PATTERN = new RegExp('(```[\\s\\S]*?```)|(`[^`\\n]+`)|(' + SLUG_PATTERN + ')', 'g')
// Wraps every bare slug in `text` with a placeholder markdown link
// (`[slug](#kb:slug)`) before it reaches `marked`, so the renderer emits a
// real `<a>` that the reader's click handler (see WikiReader.svelte) can
// intercept. The `#kb:` prefix is never a real anchor on this page — it's
// just a tag so the click handler can tell "one of ours" apart from a
// legitimate external link without inspecting every href.
//
// Trailing punctuation immediately after a slug (a period ending a
// sentence, a comma, a closing paren) is peeled off and left outside the
// link — "see host:strong." must not swallow the sentence's full stop into
// the link target.
function linkifySlugs(text: string): string {
return text.replace(TOKEN_PATTERN, (match, fence, inlineCode) => {
if (fence || inlineCode) return match // code — leave untouched, see TOKEN_PATTERN's comment
const trailing = match.match(/[.,;:)]+$/)?.[0] ?? ''
const slug = trailing ? match.slice(0, -trailing.length) : match
if (!slug.includes(':')) return match // shouldn't happen given the pattern, but stay safe
return `[${slug}](#kb:${encodeURIComponent(slug)})${trailing}`
})
}
// Full markdown render for the reader pane: linkify first (plain text, so
// the regex never sees HTML), then render, then sanitize. Mirrors
// EntityDetailContent.svelte's renderMarkdown (marked + DOMPurify, no tag
// restriction) rather than Knowledge.svelte's old snippet-only sanitize
// (which allowlisted only `<b>` for ts_headline output) — this renders a
// full note body, not a search snippet.
export function renderWikiMarkdown(text: string): string {
const linked = linkifySlugs(text)
return DOMPurify.sanitize(marked.parse(linked, { async: false }) as string)
}
// Parses a `#kb:<encoded-slug>` href back into the slug, or null if `href`
// isn't one of ours (a real external/relative link the browser should
// handle normally).
export function slugFromKbHref(href: string | null): string | null {
if (!href || !href.startsWith('#kb:')) return null
try {
return decodeURIComponent(href.slice('#kb:'.length))
} catch {
return null
}
}
// ─── Grouping (navigator tree) ─────────────────────────────────────────────
export type GroupBy = 'folder' | 'kind' | 'tag' | 'entity'
const UNGROUPED = '(ungrouped)'
// The slug format is `<kind>:<folder>/<name>` for namespaced notes (agent
// and seeded content) or plain `<kind>:<name>` for the flat runbooks
// (runbook:lifecycle-activate-node). The latter has no folder segment, so
// it groups under UNGROUPED rather than being silently dropped.
export function noteFolder(item: KnowledgeListItem): string {
const afterColon = item.slug.slice(item.slug.indexOf(':') + 1)
const idx = afterColon.lastIndexOf('/')
return idx === -1 ? UNGROUPED : afterColon.slice(0, idx)
}
export interface WikiGroup {
key: string
label: string
items: KnowledgeListItem[]
}
// Groups `items` by the chosen dimension. `tag` and `entity` are
// many-to-many — a note with three tags appears in three groups — which is
// deliberate: those two modes are for "show me everything touching X," not
// a strict partition like folder/kind are.
export function groupNotes(items: KnowledgeListItem[], by: GroupBy): WikiGroup[] {
const groups = new Map<string, KnowledgeListItem[]>()
const push = (key: string, item: KnowledgeListItem) => {
const arr = groups.get(key)
if (arr) arr.push(item)
else groups.set(key, [item])
}
for (const item of items) {
switch (by) {
case 'folder':
push(noteFolder(item), item)
break
case 'kind':
push(item.kind, item)
break
case 'tag':
if (item.tags.length === 0) push(UNGROUPED, item)
else for (const t of item.tags) push(t, item)
break
case 'entity':
if (item.about.length === 0) push(UNGROUPED, item)
else for (const slug of item.about) push(slug, item)
break
}
}
const out: WikiGroup[] = [...groups.entries()].map(([key, groupItems]) => ({
key,
label: key,
items: groupItems.sort((a, b) => a.title.localeCompare(b.title))
}))
// Ungrouped/misc always last; otherwise alphabetical, largest-first ties
// broken by label so the ordering is stable across reloads.
out.sort((a, b) => {
if (a.key === UNGROUPED) return 1
if (b.key === UNGROUPED) return -1
return a.label.localeCompare(b.label)
})
return out
}
// ─── Line diff (History tab) ───────────────────────────────────────────────
export type DiffOp = { type: 'equal' | 'add' | 'remove'; line: string }
// Textbook O(n*m) LCS-based line diff. Notes in this system are small
// (the seed data averages ~1KB, agent-written investigations rarely exceed
// 2KB, so a few dozen lines at most) — the quadratic cost is invisible at
// this size and a full Myers-diff dependency would be a lot of code for a
// feature that only needs to render a readable before/after in the History
// tab, not power a merge tool.
export function diffLines(oldText: string, newText: string): DiffOp[] {
const a = oldText.split('\n')
const b = newText.split('\n')
const n = a.length
const m = b.length
// lcs[i][j] = length of the LCS of a[i:] and b[j:]
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
}
}
const ops: DiffOp[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ type: 'equal', line: a[i] })
i++
j++
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
ops.push({ type: 'remove', line: a[i] })
i++
} else {
ops.push({ type: 'add', line: b[j] })
j++
}
}
while (i < n) ops.push({ type: 'remove', line: a[i++] })
while (j < m) ops.push({ type: 'add', line: b[j++] })
return ops
}

View File

@@ -1,162 +1,224 @@
<script lang="ts">
import DOMPurify from 'dompurify'
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge } from '$lib/api'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import { Input } from '$lib/components/ui/input'
// The Knowledge app, redesigned as a wiki: browse, read, and now actually
// edit everything the operator and Nomos have recorded — 102 notes that
// were previously stats-only (the old page could search and show a
// recency feed, but nothing was clickable: see git history on this file).
//
// Three panes (WikiTree | WikiReader | WikiContextRail) for browsing and
// editing day to day, plus a separate Cleanup mode (WikiCleanup) for the
// maintenance work the collection actually needs — duplicate pileups,
// tag-casing drift, orphaned notes, and the trash. Both modes share the
// same `items` list, loaded once here.
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { listKnowledge, KnowledgeApiError, type KnowledgeListItem } from '$lib/api'
import WikiTree from '$lib/components/knowledge/WikiTree.svelte'
import WikiReader from '$lib/components/knowledge/WikiReader.svelte'
import WikiContextRail from '$lib/components/knowledge/WikiContextRail.svelte'
import WikiCleanup from '$lib/components/knowledge/WikiCleanup.svelte'
import WikiNewDialog from '$lib/components/knowledge/WikiNewDialog.svelte'
import WikiQuickOpen from '$lib/components/knowledge/WikiQuickOpen.svelte'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { openEntityWindow } from '$lib/stores/windows'
import { relativeTime } from '$lib/utils'
import StatusBadge from '$lib/components/StatusBadge.svelte'
import SearchIcon from '@lucide/svelte/icons/search'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import BotIcon from '@lucide/svelte/icons/bot'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import BookOpenIcon from '@lucide/svelte/icons/book-open'
let query = $state('')
let results = $state<KnowledgeHit[]>([])
let loading = $state(false)
let searched = $state(false)
type Mode = 'wiki' | 'cleanup'
let recent = $state<RecentKnowledge>({ stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] })
let agentOnly = $state(false)
let loadingRecent = $state(true)
let items = $state<KnowledgeListItem[]>([])
let itemsLoading = $state(true)
let selectedSlug = $state<string | null>(null)
let mode = $state<Mode>('wiki')
let newDialogOpen = $state(false)
let quickOpenOpen = $state(false)
// True while WikiReader has an in-progress, unsaved edit — set via
// bind:dirty. requestSelect below is the single choke point every
// selection path (tree, context rail, quick-open, a newly-created note)
// goes through, so gating it here is enough to stop a stray click from
// silently discarding an edit in progress (see WikiReader's own comment
// on why this is "in edit mode" rather than a real dirty-diff).
let readerDirty = $state(false)
let pendingSlug = $state<string | null>(null)
// Distinct from "0 notes": listKnowledge now throws on a failed request
// rather than returning [] (a real outage was previously indistinguishable
// from an empty collection — see api.ts's comment on the same helper).
let loadError = $state('')
async function loadRecent() {
loadingRecent = true
recent = await fetchRecentKnowledge(agentOnly ? 'nomos-agent' : undefined)
loadingRecent = false
}
loadRecent()
function toggleAgentOnly() {
agentOnly = !agentOnly
loadRecent()
async function loadItems(): Promise<void> {
itemsLoading = true
loadError = ''
try {
items = await listKnowledge()
} catch (e) {
loadError = e instanceof KnowledgeApiError ? e.message : 'Failed to load notes.'
} finally {
itemsLoading = false
}
}
async function search() {
if (!query.trim()) { searched = false; return }
loading = true
results = await searchKnowledge(query)
loading = false
searched = true
// Cmd/Ctrl+K quick-open, scoped to while this app's window is around —
// Desktop.svelte doesn't have a global command-palette convention to hook
// into, so this is a plain window listener added/removed with the
// component's lifetime rather than a shell-level keybinding.
function handleKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
quickOpenOpen = true
}
}
onMount(() => {
loadItems()
window.addEventListener('keydown', handleKeydown)
return () => window.removeEventListener('keydown', handleKeydown)
})
// Every note's own slug, for WikiReader's click handler to tell a
// wiki-internal link (navigate in place) from an entity link (open an
// entity window) — see wikiText.ts's slugFromKbHref.
const knownSlugs = $derived(new Set(items.map((i) => i.slug)))
const selectedItem = $derived(items.find((i) => i.slug === selectedSlug) ?? null)
function selectNow(slug: string): void {
readerDirty = false
selectedSlug = slug
mode = 'wiki'
}
// Every selection path (tree click, context-rail click, quick-open,
// opening a just-created note) routes through here rather than mutating
// selectedSlug directly, so an in-progress edit can never be silently
// discarded by a stray click elsewhere in the app.
function requestSelect(slug: string): void {
if (slug === selectedSlug) {
mode = 'wiki' // already selected — e.g. Cleanup's "view" links back into Wiki mode
return
}
if (readerDirty) {
pendingSlug = slug
return
}
selectNow(slug)
}
function confirmDiscardAndSwitch(): void {
if (pendingSlug) selectNow(pendingSlug)
pendingSlug = null
}
function handleCreated(slug: string): void {
loadItems()
requestSelect(slug)
}
</script>
<div class="flex h-full flex-col gap-4 p-2">
<div class="flex h-full flex-col gap-2 p-2">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Knowledge</h1>
</div>
<!-- Learning stats: the system getting smarter, made visible -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card.Root>
<Card.Header class="p-3">
<Card.Description class="text-xs">Total notes</Card.Description>
<Card.Title class="text-2xl">{recent.stats.total}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root class="border-primary/30 bg-primary/5">
<Card.Header class="p-3">
<Card.Description class="flex items-center gap-1 text-xs"><BotIcon class="size-3" /> Written by Nomos</Card.Description>
<Card.Title class="text-2xl text-primary">{recent.stats.agent_authored}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root class="border-success/30 bg-success/5">
<Card.Header class="p-3">
<Card.Description class="flex items-center gap-1 text-xs"><SparklesIcon class="size-3" /> Learned this week</Card.Description>
<Card.Title class="text-2xl text-success">{recent.stats.last_7d}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="p-3">
<Card.Description class="text-xs">Runbooks / investigations</Card.Description>
<Card.Title class="text-2xl">{(recent.stats.by_kind.runbook ?? 0)} / {(recent.stats.by_kind.investigation ?? 0)}</Card.Title>
</Card.Header>
</Card.Root>
</div>
<!-- Search -->
<form onsubmit={(e) => { e.preventDefault(); search() }} class="flex gap-2">
<div class="relative flex-1 max-w-lg">
<SearchIcon class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search documents, runbooks, investigations…" bind:value={query} class="pl-8" />
</div>
<Button type="submit" disabled={loading || !query.trim()}>{loading ? 'Searching…' : 'Search'}</Button>
{#if searched}
<Button type="button" variant="ghost" onclick={() => { query = ''; searched = false }}>Clear</Button>
{/if}
</form>
{#if searched}
<!-- Search results mode -->
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p>
<ScrollArea class="flex-1">
<div class="flex flex-col gap-3 pr-4">
{#each results as hit (hit.slug)}
<Card.Root class="transition-colors hover:bg-muted/50">
<Card.Header>
<div class="flex items-center gap-2">
<Card.Title class="text-sm">{hit.title}</Card.Title>
<StatusBadge kind="type" value={hit.type} />
</div>
{#if hit.snippet}
<Card.Description class="text-xs">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized inline; ts_headline only ever emits <b> -->
{@html DOMPurify.sanitize(hit.snippet, { ALLOWED_TAGS: ['b'], ALLOWED_ATTR: [] })}
</Card.Description>
{/if}
{#if hit.linked_entities?.length}
<div class="mt-1 flex flex-wrap gap-1">
{#each hit.linked_entities as slug}
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntityWindow(slug)}>{slug}</button>
{/each}
</div>
{/if}
</Card.Header>
</Card.Root>
{:else}
{#if !loading}<p class="py-12 text-center text-muted-foreground">No results found.</p>{/if}
{/each}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{items.length} notes</span>
<button
type="button"
class="rounded border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:bg-muted/50"
onclick={() => (quickOpenOpen = true)}
>
⌘K
</button>
<div class="inline-flex overflow-hidden rounded-md border">
<button
type="button"
class="flex items-center gap-1 px-2 py-1 text-xs {mode === 'wiki'
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (mode = 'wiki')}
>
<BookOpenIcon class="size-3.5" /> Wiki
</button>
<button
type="button"
class="flex items-center gap-1 px-2 py-1 text-xs {mode === 'cleanup'
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (mode = 'cleanup')}
>
<WrenchIcon class="size-3.5" /> Cleanup
</button>
</div>
</ScrollArea>
{:else}
<!-- Recently learned mode (default) -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-muted-foreground">Recently learned</h2>
<Button size="sm" variant={agentOnly ? 'default' : 'outline'} class="h-7 gap-1 text-xs" onclick={toggleAgentOnly}>
<BotIcon class="size-3" /> {agentOnly ? 'Nomos only' : 'All sources'}
</Button>
</div>
<ScrollArea class="flex-1">
<div class="flex flex-col gap-2 pr-4">
{#each recent.items as it (it.slug)}
<div class="flex items-start gap-3 rounded-lg border px-3 py-2 transition-colors hover:bg-muted/40 {it.agent_authored ? 'border-primary/30 bg-primary/[0.03]' : ''}">
<div class="mt-0.5">
{#if it.agent_authored}<BotIcon class="size-4 text-primary" />{:else}<SearchIcon class="size-4 text-muted-foreground" />{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">{it.title}</span>
<StatusBadge kind="type" value={it.kind} class="text-[10px]" />
{#if it.agent_authored}<Badge variant="outline" class="border-primary/40 text-[10px] text-primary">learned by Nomos</Badge>{/if}
</div>
{#if it.tags.length}
<div class="mt-1 flex flex-wrap gap-1">
{#each it.tags as t}<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{t}</span>{/each}
</div>
{/if}
</div>
<span class="shrink-0 text-xs text-muted-foreground">{relativeTime(it.updated_at)}</span>
</div>
<div class="min-h-0 flex-1">
{#if itemsLoading}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
{:else if loadError}
<div class="flex h-full flex-col items-center justify-center gap-2 text-sm">
<p class="text-destructive">{loadError}</p>
<Button size="sm" variant="outline" onclick={loadItems}>Retry</Button>
</div>
{:else if mode === 'wiki'}
<Splitpanes theme="oikos-theme" dblClickSplitter={false} class="h-full">
<Pane size={22} minSize={15} maxSize={40}>
<div class="h-full overflow-hidden p-1.5">
<WikiTree
{items}
{selectedSlug}
onSelect={requestSelect}
onNew={() => (newDialogOpen = true)}
/>
</div>
{:else}
{#if !loadingRecent}
<p class="py-12 text-center text-sm text-muted-foreground">
{agentOnly ? 'Nomos hasnt recorded any learnings yet — it will write them here as it solves problems.' : 'No knowledge yet.'}
</p>
{/if}
{/each}
</div>
</ScrollArea>
{/if}
</Pane>
<Pane size={56} minSize={30}>
<div class="h-full overflow-hidden p-2">
<WikiReader
item={selectedItem}
allItems={items}
{knownSlugs}
onNavigate={requestSelect}
onNew={() => (newDialogOpen = true)}
onChanged={loadItems}
bind:dirty={readerDirty}
/>
</div>
</Pane>
<!-- The context rail is about the selected note, so it only exists
when there is one. Left mounted it rendered a "Nothing selected."
placeholder next to the overview — two competing empty states,
and a fifth of the width spent saying nothing. -->
{#if selectedItem}
<Pane size={22} minSize={15} maxSize={40}>
<div class="h-full overflow-hidden p-1.5">
<WikiContextRail item={selectedItem} allItems={items} onSelect={requestSelect} />
</div>
</Pane>
{/if}
</Splitpanes>
{:else}
<WikiCleanup onSelect={requestSelect} onChanged={loadItems} />
{/if}
</div>
</div>
<WikiNewDialog bind:open={newDialogOpen} onCreated={handleCreated} />
<WikiQuickOpen bind:open={quickOpenOpen} {items} onSelect={requestSelect} />
<Dialog.Root
open={pendingSlug !== null}
onOpenChange={(o) => {
if (!o) pendingSlug = null
}}
>
<Dialog.Content class="sm:max-w-sm">
<Dialog.Header>
<Dialog.Title>Discard unsaved changes?</Dialog.Title>
<Dialog.Description>
You're editing a note. Switching now will discard what you haven't saved.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (pendingSlug = null)}>Keep editing</Button>
<Button variant="destructive" onclick={confirmDiscardAndSwitch}>Discard and switch</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>