diff --git a/web/src/app.css b/web/src/app.css index 48e26ed..b11dd96 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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 diff --git a/web/src/lib/components/knowledge/WikiCleanup.svelte b/web/src/lib/components/knowledge/WikiCleanup.svelte new file mode 100644 index 0000000..90287f9 --- /dev/null +++ b/web/src/lib/components/knowledge/WikiCleanup.svelte @@ -0,0 +1,570 @@ + + +
+ 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. +
+ {#if mergeError} ++ {mergeError} +
+ {/if} + {#if duplicatesError} ++ {duplicatesError} + +
+ {/if} + {#if clusters === null} +No likely duplicates found.
+ {:else} ++ + keep as target + + + fold into it + +
++ {tagsError} + +
+ {/if} + {#if tags === null} +| Tag | +Uses | +Variants | ++ | |
|---|---|---|---|---|
|
+ {#if renaming === t.tag}
+
+
+
+
+ {:else}
+
+ {/if}
+ |
+
+ {t.uses} | ++ + + + + | ++ {#if t.split} + {t.variants.join(', ')} + {:else} + — + {/if} + | ++ {#if t.split} + + {/if} + | +
+ 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} +
+ {#if orphansError} ++ {orphansError} + +
+ {/if} + {#if orphans === null} +Nothing orphaned.
+ {:else} ++ {trashError} + +
+ {/if} + {#if trash === null} +Trash is empty.
+ {:else} +Nothing selected.
+ {:else} +Not linked to any entity.
+ {:else} +No other notes share a linked entity.
+ {:else} +No other notes share a tag.
+ {:else} ++ {error} +
+ {/if} + + + ++ The knowledge base is empty. Write the first note, or let Nomos record what it learns as it + works. +
+ +No notes match "{query}".
+ {/each} ++ {saveError} +
+ {/if} + + {#if mode === 'edit'} ++ No prior revisions — this is the first version. +
+ {:else} +{#each diff as op, i (i)}{op.type === 'add'
+ ? '+ '
+ : op.type === 'remove'
+ ? '- '
+ : ' '}{op.line}{/each}
+ {/if}
+ + {saveError} +
+ {/if} ++ No notes match “{filter}”. +
+ {/each} + 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 `` 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 `` 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:` 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 `:/` for namespaced notes (agent
+// and seeded content) or plain `:` 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()
+ 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
+}
diff --git a/web/src/pages/Knowledge.svelte b/web/src/pages/Knowledge.svelte
index cf90930..53205d7 100644
--- a/web/src/pages/Knowledge.svelte
+++ b/web/src/pages/Knowledge.svelte
@@ -1,162 +1,224 @@
-
+
Knowledge
-
-
-
-
-
-
- Total notes
- {recent.stats.total}
-
-
-
-
- Written by Nomos
- {recent.stats.agent_authored}
-
-
-
-
- Learned this week
- {recent.stats.last_7d}
-
-
-
-
- Runbooks / investigations
- {(recent.stats.by_kind.runbook ?? 0)} / {(recent.stats.by_kind.investigation ?? 0)}
-
-
-
-
-
-
-
- {#if searched}
-
- {results.length} result{results.length === 1 ? '' : 's'} for "{query}"
-
-
- {#each results as hit (hit.slug)}
-
-
-
- {hit.title}
-
-
- {#if hit.snippet}
-
-
- {@html DOMPurify.sanitize(hit.snippet, { ALLOWED_TAGS: ['b'], ALLOWED_ATTR: [] })}
-
- {/if}
- {#if hit.linked_entities?.length}
-
- {#each hit.linked_entities as slug}
-
- {/each}
-
- {/if}
-
-
- {:else}
- {#if !loading}No results found.
{/if}
- {/each}
+
+ {items.length} notes
+
+
+
+
-
- {:else}
-
-
- Recently learned
-
-
-
- {#each recent.items as it (it.slug)}
-
+
+
+ {#if itemsLoading}
+
+ Loading…
+
+ {:else if loadError}
+
+ {loadError}
+
+
+ {:else if mode === 'wiki'}
+
+
+
+ (newDialogOpen = true)}
+ />
- {:else}
- {#if !loadingRecent}
-
- {agentOnly ? 'Nomos hasn’t recorded any learnings yet — it will write them here as it solves problems.' : 'No knowledge yet.'}
-
- {/if}
- {/each}
-
-
- {/if}
+
+
+
+ (newDialogOpen = true)}
+ onChanged={loadItems}
+ bind:dirty={readerDirty}
+ />
+
+
+
+ {#if selectedItem}
+
+
+
+
+
+ {/if}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {
+ if (!o) pendingSlug = null
+ }}
+>
+
+
+ Discard unsaved changes?
+
+ You're editing a note. Switching now will discard what you haven't saved.
+
+
+
+
+
+
+
+