oficina / memoria Public

fix: three bugs the whole Go suite passed over, found by looking

tools/drive serves the app with sign-in off so a browser can see the real
pages, the way sheets/tools/drive does. It earned its place immediately.

A document starting at "##" with no "#" above it pads the heading trail,
and joining that unfiltered rendered "› Revenue" — a separator with
nothing before it. Every fixture started at "#", so nothing caught it.

The chunk was sealed as Indexable(), which is heading + body, and then
indexed as the body column while the heading also had its own column. The
heading therefore counted twice in BM25 and opened every snippet:
"Revenue June revenue was...". Now the body is sealed alone.

rastrillo's rst-form-bar is position:sticky, bottom:0 — right for a long
settings page, wrong under a textarea, where it floated the submit button
over the field it belongs to.

Screenshots confirm both themes resolve, all three stylesheets load, and
the page-header rule computes solid rather than vanishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiWVaR69Vb2Lzc9wgfit1v
Paul Campbell pushed by paul@keymail.dev 2d59af372153cb82f19652ac1c9b56f5722477c1 parent 345f908
8 files changed, +196 −8
  • internal/memoria/chunk/chunk.go +15 −1
  • internal/memoria/chunk/chunk_test.go +25 −0
  • internal/memoria/static/memoria.css +5 −0
  • internal/memoria/store.go +5 −1
  • internal/memoriatest/app_test.go +23 −0
  • internal/memoriatest/gates_test.go +0 −6
  • tools/drive/main.go +95 −0
  • tools/drive/multipart.go +28 −0
diff --git a/internal/memoria/chunk/chunk.go b/internal/memoria/chunk/chunk.go
index b3f5706..aed6335 100644
--- a/internal/memoria/chunk/chunk.go
+++ b/internal/memoria/chunk/chunk.go
@@ -41,7 +41,21 @@ type Chunk struct {
}
// Heading returns the trail joined for display and for indexing.
-func (c Chunk) Heading() string { return strings.Join(c.Trail, " › ") }
+//
+// Empty segments are dropped. The trail is padded when a document skips a
+// level — one starting at "##" with no "#" above it — and an unfiltered
+// join then renders "› Revenue" with a leading separator and nothing
+// before it. Found by looking at the page; no test saw it, because every
+// fixture started at "#".
+func (c Chunk) Heading() string {
+ parts := make([]string, 0, len(c.Trail))
+ for _, t := range c.Trail {
+ if strings.TrimSpace(t) != "" {
+ parts = append(parts, t)
+ }
+ }
+ return strings.Join(parts, " › ")
+}
// Indexable is what the index tokenises: the heading trail followed by
// the body. The trail is repeated into every chunk of a section
diff --git a/internal/memoria/chunk/chunk_test.go b/internal/memoria/chunk/chunk_test.go
index 629e811..734c596 100644
--- a/internal/memoria/chunk/chunk_test.go
+++ b/internal/memoria/chunk/chunk_test.go
@@ -72,3 +72,28 @@ func TestEmptyInputYieldsNoChunks(t *testing.T) {
}
}
}
+
+// A document that starts at "##" with no "#" above it pads the trail, and
+// an unfiltered join renders "› Revenue" — a separator with nothing before
+// it. Every earlier fixture started at "#", so nothing caught this until
+// the page was looked at.
+func TestHeadingDropsEmptyTrailSegments(t *testing.T) {
+ got := Split("## Revenue\n\n" + strings.Repeat("word ", 60))
+ if len(got) != 1 {
+ t.Fatalf("want 1 chunk, got %d", len(got))
+ }
+ if h := got[0].Heading(); h != "Revenue" {
+ t.Errorf("Heading() = %q, want %q", h, "Revenue")
+ }
+ if strings.HasPrefix(got[0].Heading(), "›") || strings.Contains(got[0].Heading(), " › ") {
+ t.Errorf("heading has a dangling separator: %q", got[0].Heading())
+ }
+}
+
+// Deeper skips too: "###" with nothing above it.
+func TestDeepSkipDoesNotDangle(t *testing.T) {
+ got := Split("### Deep\n\n" + strings.Repeat("word ", 60))
+ if len(got) != 1 || got[0].Heading() != "Deep" {
+ t.Fatalf("Heading() = %q, want %q", got[0].Heading(), "Deep")
+ }
+}
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css
index 8df56d8..6bcbdac 100644
--- a/internal/memoria/static/memoria.css
+++ b/internal/memoria/static/memoria.css
@@ -44,6 +44,11 @@
}
[rst-section] { border-top: 1px solid var(--rst-line); margin-top: 2.5rem; padding-top: 1rem; }
+
+/* rastrillo's form bar is position:sticky; bottom:0, which is right for a
+ * long settings page and wrong here — it floated the submit button over
+ * the textarea it belongs under. Ours is an ordinary row. */
+[rst-stack] [rst-form-bar] { position: static; margin-top: 1rem; }
[rst-stack] { display: flex; flex-direction: column; gap: 0.35rem; }
[rst-stack] label { font-weight: 560; margin-top: 0.6rem; }
[rst-stack] input[type="text"],
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 13ea18b..a234343 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -126,7 +126,11 @@ func (s *Store) Ingest(ctx context.Context, source, title, body string) (int64,
rows := make([]Chunk, 0, len(chunks))
for _, c := range chunks {
- st, err := s.sealer.Seal(seal.PurposeChunk, []byte(c.Indexable()))
+ // Seal the body ALONE, not Indexable(): the heading has its
+ // own indexed column, and sealing heading+body made the
+ // heading count twice in BM25 and appear at the front of
+ // every snippet. Found by reading a rendered page.
+ st, err := s.sealer.Seal(seal.PurposeChunk, []byte(c.Text))
if err != nil {
return err
}
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go
index 8c66f52..bf0c86e 100644
--- a/internal/memoriatest/app_test.go
+++ b/internal/memoriatest/app_test.go
@@ -114,3 +114,26 @@ func TestFooterStatesNoEgress(t *testing.T) {
t.Errorf("the page does not state the no-egress property:\n%s", body)
}
}
+
+// The heading has its own indexed column, so it must not also be inside
+// the body column: it would count twice in BM25 and appear at the front
+// of every snippet. Caught by reading a rendered page, not by a test —
+// hence this one.
+func TestSnippetDoesNotRepeatTheHeading(t *testing.T) {
+ h := newApp(t)
+ store(t, h, "Board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions and every product line we sell today.")
+ body := get(t, h, "/?q=june+revenue").Body.String()
+
+ // Find the snippet paragraph and check it does not open with the
+ // heading text.
+ i := strings.Index(body, "rst-result-snippet")
+ if i < 0 {
+ t.Fatalf("no snippet rendered:\n%s", body)
+ }
+ snippet := body[i:min(i+300, len(body))]
+ // The heading is "Revenue"; the passage itself begins "June revenue".
+ if strings.Contains(snippet, "Revenue</mark> <mark>June") || strings.Contains(snippet, "Revenue June revenue") {
+ t.Errorf("the snippet repeats the heading before the body:\n%s", snippet)
+ }
+}
+
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index 078ff57..e0cc950 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -253,9 +253,3 @@ func bytesContainsFold(hay []byte, needle string) bool {
return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle))
}
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/tools/drive/main.go b/tools/drive/main.go
new file mode 100644
index 0000000..4be5916
--- /dev/null
+++ b/tools/drive/main.go
@@ -0,0 +1,95 @@
+// Command drive serves memoria with no sign-in and seeds it, so a real
+// browser can look at the real pages.
+//
+// Why this exists: a Go test renders a template into a buffer and proves
+// the markup is there. It cannot see that a stylesheet failed to load,
+// that an attribute the app writes is styled by nobody, or that an app
+// rule quietly overrode a framework component. Sheets learned this the
+// hard way — a menu bar that worked perfectly from the keyboard and not
+// at all with a mouse — and its tools/drive is the committed version of
+// the same recipe.
+//
+// It builds the app with auth left nil, which makes requireSession a
+// pass-through (see app.go). That is a test seam, not a product one:
+// cmd/memoria refuses to start without sign-in configured, and
+// TestConfigRefusesIncompleteEnvironment holds it to that.
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/rastrillo/db"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+ "amadan.net/oficina/memoria/internal/memoria/seal"
+)
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:8824", "address to serve on")
+ flag.Parse()
+
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
+ dir, err := os.MkdirTemp("/home/paulca/memoriadrive", "drive")
+ if err != nil {
+ panic(err)
+ }
+ d, err := db.Open(filepath.Join(dir, "drive.db"), logger)
+ if err != nil {
+ panic(err)
+ }
+ defer d.Close()
+
+ dek := make([]byte, 32)
+ for i := range dek {
+ dek[i] = byte(i * 3)
+ }
+ sealer, err := seal.New(dek)
+ if err != nil {
+ panic(err)
+ }
+ _ = base64.StdEncoding
+
+ mux, _, err := memoria.App(d, "http://"+*addr, sealer, logger)
+ if err != nil {
+ panic(err)
+ }
+
+ // Seed enough to make every surface non-empty: results, a heading
+ // trail, a highlight, a document list with more than one row.
+ store := memoria.NewStore(d, sealer, nil)
+ _ = store
+ seedViaHTTP(mux, *addr)
+
+ fmt.Printf("drive serving on http://%s (no sign-in)\n", *addr)
+ if err := http.ListenAndServe(*addr, mux); err != nil {
+ panic(err)
+ }
+}
+
+// seedViaHTTP posts documents through the real handler, so the seeded
+// state is exactly what a person would produce.
+func seedViaHTTP(h http.Handler, addr string) {
+ docs := []struct{ title, body string }{
+ {"Q3 board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions, up from 1,180,000 in the same month last year. The increase is almost entirely the enterprise tier.\n\n## Costs\n\nHosting costs fell after the migration, and support headcount was flat.\n\n## Risks\n\nOne customer is 22% of recurring revenue. That concentration is the single largest risk on this page and it has not improved since Q2."},
+ {"Sealing notes", "## The one rule\n\nA sheet's contents are sealed at rest and the bucket never holds a plaintext cell. The instance holds the key while it runs, so a compromised live host can read a sheet it has open — that is a deliberate softening and the README says so.\n\n## What it does not cover\n\nEmbeddings are content: a vector inverts back to text well enough that an unsealed index is a plaintext cell in a costume."},
+ {"Meeting, 12 August", "## Present\n\nEveryone.\n\n## Decisions\n\nWe will not build a suite index app. It answers a different question from the one anyone asked, and per-item access control is not expressible by the app that would have to publish it.\n\n## Actions\n\nPaul to decide whether the central host is a directory or an authenticator."},
+ }
+ for _, doc := range docs {
+ body, ctype := multipartBody(doc.title, doc.body)
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://"+addr+"/documents", body)
+ req.Header.Set("Content-Type", ctype)
+ req.Header.Set("Origin", "http://"+addr)
+ rec := &recorder{header: http.Header{}}
+ h.ServeHTTP(rec, req)
+ if rec.code != http.StatusSeeOther {
+ fmt.Fprintf(os.Stderr, "seed %q: status %d\n", doc.title, rec.code)
+ }
+ }
+}
diff --git a/tools/drive/multipart.go b/tools/drive/multipart.go
new file mode 100644
index 0000000..f91e904
--- /dev/null
+++ b/tools/drive/multipart.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "mime/multipart"
+ "net/http"
+)
+
+func multipartBody(title, body string) (io.Reader, string) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ mw.WriteField("title", title)
+ mw.WriteField("body", body)
+ mw.Close()
+ return &buf, mw.FormDataContentType()
+}
+
+// recorder is a minimal ResponseWriter — httptest is a test-only package
+// and this is a command.
+type recorder struct {
+ code int
+ header http.Header
+}
+
+func (r *recorder) Header() http.Header { return r.header }
+func (r *recorder) Write(b []byte) (int, error) { return len(b), nil }
+func (r *recorder) WriteHeader(c int) { r.code = c }