Initial commit: knox knowledge index

This commit is contained in:
2026-08-29 02:52:32 -07:00
commit 8eecde18b4
31 changed files with 6855 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
{{define "head"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Knox{{if .title}} · {{.title}}{{end}}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>body{font-family:system-ui,sans-serif}</style>
</head>
<body class="bg-gray-950 text-gray-100 min-h-screen">
<header class="border-b border-gray-800 p-4 flex items-center justify-between">
<h1 class="text-xl font-bold text-emerald-400"><a href="/">Knox</a>{{if .title}} <span class="text-gray-400 text-sm font-normal">/ {{.title}}</span>{{end}}</h1>
<nav class="flex gap-4 text-sm">
<a href="/" class="text-gray-300 hover:text-white">Dashboard</a>
<a href="/threads" class="text-gray-300 hover:text-white">Threads</a>
</nav>
</header>
{{end}}
{{define "foot"}}</body>
</html>
{{end}}
{{define "not_found"}}
{{template "head" .}}
<main class="max-w-4xl mx-auto p-6 text-center">
<div class="text-gray-500 text-lg">Page not found</div>
<a href="/" class="mt-6 inline-block text-emerald-400 hover:text-emerald-300 text-sm">Back to Dashboard</a>
</main>
{{template "foot" .}}
{{end}}
{{define "dashboard"}}
{{template "head" .}}
<main class="max-w-6xl mx-auto p-6 space-y-6">
{{if .golden}}
<div class="bg-emerald-900/30 border border-emerald-800 rounded-lg p-4">
<div class="text-xs text-emerald-400 uppercase tracking-wider">Golden Thread</div>
<div class="text-lg font-semibold mt-1">{{.golden.Title}}</div>
<div class="text-sm text-gray-400">{{.golden.Motivation}}</div>
</div>
{{end}}
<div class="flex gap-4">
<input type="text" name="q" placeholder="Search knowledge index..."
class="flex-1 bg-gray-900 border border-gray-700 rounded px-4 py-2 text-sm focus:outline-none focus:border-emerald-500"
hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results" hx-indicator="#search-indicator">
<button class="bg-emerald-700 hover:bg-emerald-600 px-4 py-2 rounded text-sm font-medium"
hx-get="/search" hx-include="[name=q]" hx-target="#results" hx-indicator="#search-indicator">Search</button>
</div>
<div id="results" class="text-sm text-gray-500"></div>
<div id="search-indicator" class="htmx-indicator text-xs text-emerald-400">Searching...</div>
<div hx-get="/stats" hx-trigger="load, every 30s" hx-swap="innerHTML">
<div class="text-gray-500">Loading stats...</div>
</div>
<section>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Issues</h2>
{{if .gitea}}
<div class="space-y-1 mb-4">
{{range .gitea}}
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
<span class="text-emerald-300">{{trunc .Title 80}}</span>
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
</a>
{{end}}
</div>
{{end}}
</section>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Notes</h3>
{{if .obsidian}}
<div class="space-y-1">
{{range .obsidian}}
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
<span>{{trunc .Title 50}}</span>
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
</a>
{{end}}
</div>
{{else}}
<div class="text-gray-500 text-xs">No notes indexed.</div>
{{end}}
</section>
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Sessions</h3>
{{if .session}}
<div class="space-y-1">
{{range .session}}
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
<span>{{trunc .Title 50}}</span>
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
</a>
{{end}}
</div>
{{else}}
<div class="text-gray-500 text-xs">No sessions tracked.</div>
{{end}}
</section>
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Skills</h3>
{{if .skills}}
<div class="space-y-1">
{{range .skills}}
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
<span>{{trunc .Title 50}}</span>
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
</a>
{{end}}
</div>
{{else}}
<div class="text-gray-500 text-xs">No skills indexed.</div>
{{end}}
</section>
</div>
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Feed</h3>
<div class="space-y-1">
{{range .recent}}
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
<span class="font-mono text-xs text-gray-500">{{shortFP .Fingerprint}}</span>
<span class="ml-2">{{trunc .Title 60}}</span>
<span class="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{{.SourceID}}</span>
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
</a>
{{else}}
<div class="text-gray-500 text-sm">No entries yet.</div>
{{end}}
</div>
</section>
</main>
{{template "foot" .}}
{{end}}
{{define "search_results"}}
{{if .results}}
<div class="space-y-1">
<div class="text-xs text-gray-500 mb-2">{{len .results}} results for "{{.query}}"</div>
{{range .results}}
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
<div>
<span class="font-mono text-xs text-gray-500">{{shortFP .Fingerprint}}</span>
<span class="ml-2 font-medium">{{trunc .Title 70}}</span>
<span class="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{{.SourceID}}</span>
</div>
{{if .Summary}}<div class="text-gray-500 text-xs mt-0.5 ml-12">{{trunc .Summary 120}}</div>{{end}}
</a>
{{end}}
</div>
{{else}}
<p class="text-gray-500 text-sm">No results for "{{.query}}".</p>
{{end}}
{{end}}
{{define "stats"}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{{range .stats}}
<div class="bg-gray-900 rounded-lg p-3 border border-gray-800">
<div class="text-xs text-gray-500 uppercase tracking-wider">{{.Label}}</div>
<div class="text-xl font-semibold mt-1">{{.Value}}</div>
</div>
{{end}}
</div>
{{end}}
{{define "thread_list"}}
{{template "head" .}}
<main class="max-w-4xl mx-auto p-6 space-y-4">
<div class="flex gap-2 mb-4">
<a href="/threads" class="px-3 py-1 text-sm rounded {{if eq .status ""}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">All</a>
<a href="/threads?status=active" class="px-3 py-1 text-sm rounded {{if eq .status "active"}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">Active</a>
<a href="/threads?status=stalled" class="px-3 py-1 text-sm rounded {{if eq .status "stalled"}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">Stalled</a>
<a href="/threads?status=resolved" class="px-3 py-1 text-sm rounded {{if eq .status "resolved"}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">Resolved</a>
</div>
{{range .threads}}
<a href="/threads/{{.ID}}" class="block bg-gray-900 rounded-lg p-4 border border-gray-800 hover:border-gray-600">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{{.Title}}</span>
<span class="text-xs px-1.5 py-0.5 rounded {{if eq .Status "active"}}bg-emerald-900 text-emerald-300{{else if eq .Status "resolved"}}bg-blue-900 text-blue-300{{else}}bg-gray-800 text-gray-400{{end}}">{{.Status}}</span>
<span class="text-xs text-gray-500">{{.Priority}}</span>
</div>
{{if .Motivation}}<div class="text-sm text-gray-400 mt-1">{{.Motivation}}</div>{{end}}
<div class="text-xs text-gray-600 mt-1">{{.EntryCount}} observations · {{humanTime .UpdatedAt}}</div>
</a>
{{else}}
<div class="text-gray-500 text-sm">No threads found.</div>
{{end}}
</main>
{{template "foot" .}}
{{end}}
{{define "thread_detail"}}
{{template "head" .}}
<main class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="flex items-center gap-2">
<h2 class="text-xl font-bold">{{.thread.Title}}</h2>
{{if .isGolden}}<span class="text-xs bg-emerald-900 text-emerald-300 px-2 py-0.5 rounded">GOLDEN</span>{{end}}
</div>
<div class="flex gap-4 mt-2 text-sm text-gray-400">
<span>{{.thread.Status}}</span>
<span>{{.thread.Priority}}</span>
<span>Created {{humanTime .thread.CreatedAt}}</span>
</div>
{{if .thread.Motivation}}<div class="mt-3 text-gray-300"><strong>Motivation:</strong> {{.thread.Motivation}}</div>{{end}}
{{if ne .thread.Provenance "{}"}}<div class="mt-1 text-xs text-gray-500"><strong>Provenance:</strong> {{.thread.Provenance}}</div>{{end}}
</div>
{{if .provenance}}
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Provenance Graph</h3>
<div class="bg-gray-900 rounded-lg p-3 border border-gray-800 text-sm font-mono">
{{range .provenance}}
<div class="text-gray-400">└─ <a href="{{fpURL .Fingerprint}}" class="text-emerald-400 hover:text-emerald-300">{{shortFP .Fingerprint}}</a> <span class="text-gray-600">[{{.Relation}}]</span></div>
{{end}}
</div>
</section>
{{end}}
{{if .obs}}
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Observations ({{len .obs}})</h3>
<div class="space-y-1">
{{range .obs}}
<div class="bg-gray-900 rounded px-3 py-2 text-sm flex items-center gap-3">
<span class="font-mono text-xs text-gray-500">#{{.ID}}</span>
<span class="flex-1">{{trunc .Title 50}}</span>
<span class="text-xs text-gray-500">{{.SourceID}}</span>
{{if gt .Count 1}}<span class="text-xs text-amber-500" title="seen {{.Count}} times">×{{.Count}}</span>{{end}}
<span class="text-xs text-gray-600">{{humanTime .CollectedAt}}</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if .notes}}
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Notes</h3>
<div class="space-y-2">
{{range .notes}}
<div class="bg-gray-900 rounded p-3 border border-gray-800">
<div class="text-xs text-gray-500">{{humanTime .CreatedAt}}</div>
<div class="text-sm mt-1">{{.Note}}</div>
</div>
{{end}}
</div>
</section>
{{end}}
</main>
{{template "foot" .}}
{{end}}
{{define "entry_detail"}}
{{template "head" .}}
<main class="max-w-4xl mx-auto p-6 space-y-4">
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-500">Fingerprint</span><br><span class="font-mono text-xs">{{.entry.Fingerprint}}</span></div>
<div><span class="text-gray-500">Source</span><br>{{.entry.SourceID}}</div>
<div><span class="text-gray-500">Title</span><br><span class="font-medium">{{.entry.Title}}</span></div>
<div><span class="text-gray-500">Project</span><br>{{.entry.Project}}</div>
<div><span class="text-gray-500">Path</span><br><span class="font-mono text-xs">{{.entry.SourcePath}}</span></div>
<div><span class="text-gray-500">Type</span><br>{{.entry.ContentType}}</div>
<div><span class="text-gray-500">Signal Time</span><br>{{if .entry.CreatedAt}}{{humanTime .entry.CreatedAt}}{{else}}<span class="text-gray-500">—</span>{{end}}</div>
<div><span class="text-gray-500">Ingested</span><br>{{humanTime .entry.LastSeen}}</div>
<div><span class="text-gray-500">Ref Count</span><br>{{.entry.RefCount}}</div>
<div><span class="text-gray-500">Confidence</span><br>{{printf "%.1f" .entry.LastConfidence}}</div>
</div>
{{if .entry.Summary}}<div class="mt-4 p-3 bg-gray-800 rounded text-sm">{{.entry.Summary}}</div>{{end}}
</div>
{{if .obs}}
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Observation History ({{len .obs}})</h3>
<div class="space-y-1">
{{range .obs}}
<div class="bg-gray-900 rounded px-3 py-2 text-sm flex items-center gap-3">
<span class="font-mono text-xs text-gray-500">#{{.ID}}</span>
<span class="text-xs text-gray-500">{{humanTime .CollectedAt}}</span>
{{if gt .Count 1}}<span class="text-xs text-amber-500" title="seen {{.Count}} times, unchanged">×{{.Count}}</span>{{end}}
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{{.Trigger}}</span>
<span class="text-xs text-gray-500">{{.IngesterVersion}}</span>
<span class="text-xs text-gray-500">conf:{{printf "%.1f" .Confidence}}</span>
</div>
{{end}}
</div>
</section>
{{end}}
</main>
{{template "foot" .}}
{{end}}
+283
View File
@@ -0,0 +1,283 @@
package server
import (
"bytes"
_ "embed"
"fmt"
"html/template"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/david/knox/internal/db"
)
type Server struct {
kdb *db.KnoxDB
mux *http.ServeMux
tmpl *template.Template
}
func New(kdb *db.KnoxDB) *Server {
s := &Server{kdb: kdb, mux: http.NewServeMux()}
s.tmpl = template.Must(template.New("knox").Funcs(template.FuncMap{
"shortFP": shortFP,
"trunc": truncStr,
"fpURL": fpURL,
"humanTime": humanTime,
"displayTime": displayTime,
}).Parse(layout))
s.mux.HandleFunc("GET /", s.dashboard)
s.mux.HandleFunc("GET /search", s.search)
s.mux.HandleFunc("GET /threads", s.threadList)
s.mux.HandleFunc("GET /threads/{id}", s.threadDetail)
s.mux.HandleFunc("GET /entries/{fp}", s.entryDetail)
s.mux.HandleFunc("GET /stats", s.stats)
return s
}
func (s *Server) Serve(addr string) error {
log.Printf("[knox] web UI at http://%s", addr)
return http.ListenAndServe(addr, s.mux)
}
// ─── Dashboard ───────────────────────────────────────────────
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
gitea, _ := s.kdb.RecentEntriesBySource("gitea", 3)
obsidian, _ := s.kdb.RecentEntriesBySource("obsidian", 3)
skills, _ := s.kdb.RecentEntriesBySource("skills-catalog", 2)
session, _ := s.kdb.RecentEntriesBySource("opencode-session", 3)
recent, _ := s.kdb.RecentEntries(8)
goldenID, err := s.kdb.GoldenThreadID()
if err != nil {
http.Error(w, "failed to load golden thread", http.StatusInternalServerError)
return
}
var golden *db.Thread
if goldenID > 0 {
golden, _ = s.kdb.GetThread(goldenID)
}
s.render(w, "dashboard", map[string]any{
"gitea": gitea,
"obsidian": obsidian,
"skills": skills,
"session": session,
"recent": recent,
"golden": golden,
})
}
// ─── Search ──────────────────────────────────────────────────
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<p class='text-gray-400 text-sm'>Enter a query above.</p>"))
return
}
results, err := s.kdb.Search(q, 30)
if err != nil {
http.Error(w, "search failed", http.StatusInternalServerError)
return
}
s.render(w, "search_results", map[string]any{
"query": q,
"results": results,
})
}
// ─── Thread List ─────────────────────────────────────────────
func (s *Server) threadList(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
threads, err := s.kdb.ListThreads(status)
if err != nil {
http.Error(w, "failed to load threads", http.StatusInternalServerError)
return
}
s.render(w, "thread_list", map[string]any{
"title": "Threads",
"threads": threads,
"status": status,
})
}
// ─── Thread Detail ───────────────────────────────────────────
func (s *Server) threadDetail(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid thread id", http.StatusBadRequest)
return
}
t, err := s.kdb.GetThread(id)
if err != nil {
http.Error(w, "failed to load thread", http.StatusInternalServerError)
return
}
if t == nil {
s.render(w, "not_found", map[string]any{"title": "Thread Not Found"}, http.StatusNotFound)
return
}
obs, err := s.kdb.ThreadObservations(id)
if err != nil {
http.Error(w, "failed to load observations", http.StatusInternalServerError)
return
}
notes, err := s.kdb.ThreadNotes(id)
if err != nil {
http.Error(w, "failed to load notes", http.StatusInternalServerError)
return
}
provenance, err := s.kdb.ThreadProvenance(id)
if err != nil {
http.Error(w, "failed to load provenance", http.StatusInternalServerError)
return
}
goldenID, _ := s.kdb.GoldenThreadID()
s.render(w, "thread_detail", map[string]any{
"title": fmt.Sprintf("Thread #%d", id),
"thread": t,
"obs": obs,
"notes": notes,
"provenance": provenance,
"isGolden": goldenID == id,
})
}
// ─── Entry Detail ────────────────────────────────────────────
func (s *Server) entryDetail(w http.ResponseWriter, r *http.Request) {
fp := r.PathValue("fp")
entry, err := s.kdb.FindEntry(fp)
if err != nil {
http.Error(w, "failed to load entry", http.StatusInternalServerError)
return
}
if entry == nil {
// Try fingerprint prefix match (the UI displays 8-char prefixes)
entries, err := s.kdb.FindEntryByPrefix(fp, 2)
if err != nil {
http.Error(w, "failed to load entry", http.StatusInternalServerError)
return
}
if len(entries) == 1 {
entry = &entries[0]
} else {
s.render(w, "not_found", map[string]any{"title": "Entry Not Found"}, http.StatusNotFound)
return
}
}
obs, err := s.kdb.ObservationsByFingerprint(entry.Fingerprint, 20)
if err != nil {
http.Error(w, "failed to load observations", http.StatusInternalServerError)
return
}
s.render(w, "entry_detail", map[string]any{
"title": "Entry " + shortFP(entry.Fingerprint),
"entry": entry,
"obs": obs,
})
}
// ─── Stats Fragment ──────────────────────────────────────────
type statItem struct {
Label string
Value any
}
func statItems(stats map[string]any) []statItem {
keys := make([]string, 0, len(stats))
for k := range stats {
keys = append(keys, k)
}
sort.Strings(keys)
items := make([]statItem, 0, len(keys))
for _, k := range keys {
items = append(items, statItem{
Label: strings.ReplaceAll(k, "_", " "),
Value: stats[k],
})
}
return items
}
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
stats, err := s.kdb.Stats()
if err != nil {
http.Error(w, "failed to load stats", http.StatusInternalServerError)
return
}
s.render(w, "stats", map[string]any{"stats": statItems(stats)})
}
// ─── Render ──────────────────────────────────────────────────
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any, status ...int) {
code := http.StatusOK
if len(status) > 0 {
code = status[0]
}
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
log.Printf("[knox] template error: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
buf.WriteTo(w)
}
func shortFP(fp string) string {
if len(fp) > 8 {
return fp[:8]
}
return fp
}
// fpURL routes synthetic thread fingerprints to thread pages.
func fpURL(fp string) string {
if id, ok := strings.CutPrefix(fp, "thread:"); ok {
return "/threads/" + id
}
return "/entries/" + fp
}
func truncStr(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}
func displayTime(e db.Entry) string {
if e.CreatedAt != "" {
return humanTime(e.CreatedAt)
}
return humanTime(e.LastSeen)
}
func humanTime(s string) string {
formats := []string{time.RFC3339, "2006-01-02 15:04:05"}
for _, f := range formats {
t, err := time.Parse(f, s)
if err == nil {
return t.Local().Format("Jan 2, 2006 · 15:04")
}
}
return s
}
//go:embed layout.html
var layout string