feat: a seed tool, and a README you can follow at 8am
seed ingests a directory straight into the store โ same chunking, same sealing, no network, and it refuses to run without the DEK the app uses. It exists so a fresh instance opens on a corpus worth searching, and it is the honest ancestor of slice 3's connectors. The local instance is seeded with every .md in the oficina tree: 55 documents, 649 passages. The first thing memoria can answer questions about is the suite's own specs, which is a fair test of whether the retrieval is any good. Sign-in turned out to go through keymail OAuth rather than self-hosted magic links, so the owners list carries both of Paul's addresses. Verified keymail accepts a loopback client_id, so the local flow works in a browser.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiWVaR69Vb2Lzc9wgfit1v
8 files changed,
+413
−8
README.md+102 −0go.mod+1 −0go.sum+2 −0internal/memoria/index/index.go+33 −6internal/memoria/index/index_test.go+40 −0internal/memoria/render.go+2 −2internal/memoriatest/app_test.go+116 −0tools/seed/main.go+117 −0
diff --git a/README.md b/README.md| new file mode 100644 |
| index 0000000..84f67a8 |
| --- /dev/null |
| +++ b/README.md |
| @@ -0,0 +1,102 @@ |
| +# ๐ค memoria |
| + |
| +The Oficina suite's memory. One instance per team. |
| + |
| +Ask a question, get an answer grounded in your team's own documents โ |
| +with a citation you can open, always. |
| + |
| +**Slice 0 is what exists today: a search engine, not an assistant.** It |
| +holds text you give it, seals it, and searches it. There is no model call |
| +anywhere in this binary, which means the honest version of the trust |
| +claim is unusually strong right now: |
| + |
| +> memoria's passages and its index are sealed at rest under this team's |
| +> own key. **Nothing leaves this instance** โ not at query time, and not |
| +> at indexing time, because nothing is sent anywhere to be indexed. |
| + |
| +That changes the day the assistant arrives, and the design spec is |
| +explicit about how: embeddings come from a hosted API, so switching the |
| +assistant on sends **every document in the corpus** to a named provider |
| +once, at indexing time โ not just the passages behind an answer. See |
| +ยง3.2 and ยง3.6 of the design. |
| + |
| +## Trying it locally |
| + |
| +``` |
| +cd /home/paulca/memoriadrive |
| +./run.sh |
| +``` |
| + |
| +Then open **http://127.0.0.1:8823** and sign in. Sign-in goes through |
| +keymail, so use an address on the owners list in `run.sh` |
| +(`paul@tito.io` or `paul@keymail.dev` as it stands). |
| + |
| +It comes pre-seeded with 55 documents and 649 passages โ every `.md` in |
| +the `oficina` tree, so the first thing you can search is the suite's own |
| +specs. Try `sealed at rest`, `grants join`, `revoke`, `escritorio`, |
| +`plausible empty list`. |
| + |
| +Things worth poking at, because each is a decision rather than an |
| +accident: |
| + |
| +- Search **ORs** its terms. Ask it a question in words โ "what is the |
| + worst available failure shape" โ and it works. ANDing would return |
| + nothing, and nothing is indistinguishable from having nothing. |
| +- A no-match page tells you how much *is* indexed, so an absent answer |
| + never looks like an empty index. |
| +- Every result cites a document and a passage number, and the passage |
| + anchors when you follow it. |
| + |
| +### Adding your own |
| + |
| +Paste or upload text on the front page, or seed a directory: |
| + |
| +``` |
| +cd /home/paulca/memoriadrive |
| +MEMORIA_DEK="$(cat DEK.txt)" ./seed --db m.db --dir /path/to/notes --source notes |
| +``` |
| + |
| +The seed tool uses the same store, chunker and sealing as the app. It is |
| +the honest ancestor of slice 3's connectors, and it bypasses nothing โ |
| +without the right DEK it refuses to run. |
| + |
| +### The DEK |
| + |
| +`DEK.txt` in `memoriadrive` is the key everything is sealed with. **Lose |
| +it and the corpus is gone**, exactly as with Sheets' `SHEETS_DEK`. The |
| +app never generates one: a generated fallback would seal this boot's data |
| +under a key the next boot cannot derive, losing the data quietly instead |
| +of refusing loudly. |
| + |
| +## Deploying |
| + |
| +Not done โ this has never run anywhere but localhost. When you want it on |
| +the estate: |
| + |
| +``` |
| +make release |
| +carlos deploy --app memoria --account bds --channel edge --timeout 5m ./releases/memoria-arm64 |
| +``` |
| + |
| +Flags go **before** the binary path; Go's flag package stops parsing at |
| +the first non-flag argument, so a path first makes `--account` invisible. |
| +`carlos deploy`, never `carlos ship` on its own. Then poll |
| +`GET /api/version` rather than trusting `X-Carlos-Version`, which is the |
| +router's adopted release and flips before the process cycles. |
| + |
| +## The design |
| + |
| +`docs/superpowers/specs/2026-09-03-memoria-design.md`. Draft 2, which |
| +answers independent reviews by GPT Sol and Fable that both returned |
| +REQUEST CHANGES against draft 1. ยง1.2 records what changed and why โ the |
| +central correction being that authorization is asked of whichever app |
| +owns a document, per person, per query, rather than derived from a |
| +membership table memoria keeps. |
| + |
| +`QUESTIONS-FOR-PAUL.md` is what I could not decide alone. |
| + |
| +## What is not here |
| + |
| +No assistant, no embeddings, no sibling apps, no external connectors, no |
| +snapshot of the index (it rebuilds at boot โ milliseconds at this size, |
| +and ยง12 carries the trigger for changing that). The sequencing is ยง11. |
diff --git a/go.mod b/go.mod| index 2854485..088d0aa 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -11,6 +11,7 @@ require ( |
| ) |
| require ( |
| + github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
diff --git a/go.sum b/go.sum| index 8024e0f..6ee94a5 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,3 +1,5 @@ |
| +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
| github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
diff --git a/internal/memoria/index/index.go b/internal/memoria/index/index.go| index 0d9f9b3..d06af09 100644 |
| --- a/internal/memoria/index/index.go |
| +++ b/internal/memoria/index/index.go |
| @@ -13,7 +13,9 @@ |
| package index |
| import ( |
| + "crypto/rand" |
| "database/sql" |
| + "encoding/hex" |
| "fmt" |
| "strings" |
| "sync" |
| @@ -44,12 +46,25 @@ type Index struct { |
| // Open builds an empty index. The DSN is in-memory with a shared cache so |
| // the single handle is stable; nothing is written to any file. |
| func Open() (*Index, error) { |
| - // `file::memory:` with cache=shared keeps one database alive across |
| - // pooled connections. Without it each connection gets its own empty |
| - // database and searches return nothing โ a plausible empty list, |
| - // which is the failure shape this codebase treats as the worst |
| - // available, so it is worth the explicit DSN. |
| - db, err := sql.Open("sqlite", "file:memoria-index?mode=memory&cache=shared") |
| + // `cache=shared` keeps one database alive across pooled connections. |
| + // Without it each connection gets its own empty database and searches |
| + // return nothing โ a plausible empty list, which is the failure shape |
| + // this codebase treats as the worst available, so it is worth the |
| + // explicit DSN. |
| + // |
| + // The name must be UNIQUE PER INDEX, and that is the load-bearing |
| + // half. A shared cache is keyed by name, so a fixed name makes every |
| + // Index in the process the same database. Two apps in one process |
| + // then share one index: the second Open fails with "table chunks |
| + // already exists" if you are lucky, and if the schema ever matched it |
| + // would instead merge two teams' passages into one searchable |
| + // corpus โ the cross-team leak ยง6.3 exists to make impossible. Found |
| + // by the test suite building two apps in one process. |
| + name, err := randomName() |
| + if err != nil { |
| + return nil, err |
| + } |
| + db, err := sql.Open("sqlite", "file:"+name+"?mode=memory&cache=shared") |
| if err != nil { |
| return nil, err |
| } |
| @@ -218,3 +233,15 @@ func MatchQuery(raw string) string { |
| } |
| return strings.Join(terms, " OR ") |
| } |
| + |
| +// randomName gives each in-memory index its own shared-cache key. Random |
| +// rather than a counter: a counter resets when the process restarts, and |
| +// two indexes alive across a restart boundary in a test binary would |
| +// collide again. |
| +func randomName() (string, error) { |
| + b := make([]byte, 8) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", fmt.Errorf("index: name: %w", err) |
| + } |
| + return "memoria-index-" + hex.EncodeToString(b), nil |
| +} |
diff --git a/internal/memoria/index/index_test.go b/internal/memoria/index/index_test.go| index 24edb99..d241c87 100644 |
| --- a/internal/memoria/index/index_test.go |
| +++ b/internal/memoria/index/index_test.go |
| @@ -155,3 +155,43 @@ func TestOrRecallStillRanksBestMatchFirst(t *testing.T) { |
| t.Errorf("best hit is chunk %d, want 2 โ ranking is not compensating for OR", hits[0].ChunkID) |
| } |
| } |
| + |
| +// Two indexes in one process must be independent. They were not: the |
| +// shared-cache DSN used a fixed name, so every Index was the same |
| +// in-memory database. The visible symptom was "table chunks already |
| +// exists"; the invisible one, had the schema matched, would have been two |
| +// teams' passages merged into one searchable corpus. |
| +func TestTwoIndexesAreIndependent(t *testing.T) { |
| + a, err := Open() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer a.Close() |
| + b, err := Open() |
| + if err != nil { |
| + t.Fatalf("opening a second index failed: %v", err) |
| + } |
| + defer b.Close() |
| + |
| + if err := a.Add(Entry{ChunkID: 1, DocumentID: 1, Heading: "A", Text: "alpha secret"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + hits, err := b.Search("alpha", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(hits) != 0 { |
| + t.Fatalf("index B can see index A's passages: %+v โ indexes are sharing state", hits) |
| + } |
| + na, err := a.Count() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nb, err := b.Count() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if na != 1 || nb != 0 { |
| + t.Errorf("counts are A=%d B=%d, want 1 and 0", na, nb) |
| + } |
| +} |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index f77a30d..9c60571 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -35,8 +35,8 @@ func init() { |
| // chosen delivery needs โ empty for the vendored-inline |
| // default, so the layout calls it unconditionally and |
| // switching delivery later needs no template edit. |
| - "icon": icons.Icon, |
| - "iconAssets": icons.Assets, |
| + "icon": icons.Icon, |
| + "iconAssets": icons.Assets, |
| "safeSnippet": safeSnippet, |
| }). |
| ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go| new file mode 100644 |
| index 0000000..8c66f52 |
| --- /dev/null |
| +++ b/internal/memoriatest/app_test.go |
| @@ -0,0 +1,116 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestStoreThenFind(t *testing.T) { |
| + h := newApp(t) |
| + if rec := store(t, h, "Q3 board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions.\n\n## Costs\n\nHosting fell after the migration."); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("store: status %d, want 303", rec.Code) |
| + } |
| + body := get(t, h, "/?q=june+revenue").Body.String() |
| + if !strings.Contains(body, "Q3 board pack") { |
| + t.Errorf("search does not show the document title:\n%s", body) |
| + } |
| + if !strings.Contains(body, "Revenue") { |
| + t.Error("search result carries no heading trail") |
| + } |
| + if !strings.Contains(body, "<mark>") { |
| + t.Error("search result carries no highlight") |
| + } |
| +} |
| + |
| +// The list must distinguish "nothing matched" from "nothing indexed". |
| +// Conflating them is the plausible-empty-list failure. |
| +func TestNoMatchSaysTheCorpusIsNotEmpty(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Notes", strings.Repeat("hosting migration notes. ", 20)) |
| + body := get(t, h, "/?q=xylophone").Body.String() |
| + if !strings.Contains(body, "0 results") { |
| + t.Errorf("no-match page does not state the result count:\n%s", body) |
| + } |
| + if !strings.Contains(body, "indexed") { |
| + t.Error("no-match page does not say how much is indexed, so a reader " + |
| + "cannot tell an absent answer from an empty index") |
| + } |
| +} |
| + |
| +// Stored text is untrusted. A document containing markup must not |
| +// execute in a search result or on the document page. |
| +func TestStoredMarkupIsEscaped(t *testing.T) { |
| + h := newApp(t) |
| + const nasty = `<script>alert('xss')</script>` |
| + store(t, h, "Payload "+nasty, "## Heading "+nasty+"\n\nA passage containing "+nasty+" inline and revenue too.") |
| + |
| + for _, target := range []string{"/?q=revenue", "/documents/1"} { |
| + body := get(t, h, target).Body.String() |
| + if strings.Contains(body, "<script>") { |
| + t.Errorf("%s renders an unescaped <script>:\n%s", target, body) |
| + } |
| + if !strings.Contains(body, "<script>") { |
| + t.Errorf("%s does not show the escaped form, so the payload may have been dropped "+ |
| + "rather than escaped โ check the snippet path", target) |
| + } |
| + } |
| +} |
| + |
| +// Re-storing the same text updates one document rather than making two |
| +// that disagree. The ExternalID is the content hash, so this is what |
| +// makes a re-upload idempotent. |
| +func TestReStoringSameTextDoesNotDuplicate(t *testing.T) { |
| + h := newApp(t) |
| + const text = "## Revenue\n\nJune revenue was 1,350,000 across every region we serve." |
| + store(t, h, "First", text) |
| + store(t, h, "Second", text) |
| + body := get(t, h, "/").Body.String() |
| + if !strings.Contains(body, "1 document") { |
| + t.Errorf("re-storing identical text made more than one document:\n%s", body) |
| + } |
| +} |
| + |
| +// A changed document must not leave its old passages searchable: a |
| +// citation to text that is no longer there is worse than a missing one. |
| +func TestRevisingRemovesTheOldPassages(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Doc", "## Revenue\n\nThe zebrafinch figure was 100.") |
| + if !strings.Contains(get(t, h, "/?q=zebrafinch").Body.String(), "Doc") { |
| + t.Fatal("the first version was not indexed") |
| + } |
| + // Same title, different body: a new document under this design, |
| + // since the hash differs. Delete the first and confirm it is gone. |
| + if rec := post(t, h, "/documents/1/delete", nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("delete: status %d, want 303", rec.Code) |
| + } |
| + body := get(t, h, "/?q=zebrafinch").Body.String() |
| + if strings.Contains(body, "<mark>") { |
| + t.Errorf("a deleted document is still searchable:\n%s", body) |
| + } |
| +} |
| + |
| +func TestMissingDocument404s(t *testing.T) { |
| + if rec := get(t, newApp(t), "/documents/999"); rec.Code != http.StatusNotFound { |
| + t.Errorf("GET /documents/999: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +func TestEmptySubmissionIsRefusedWithCopy(t *testing.T) { |
| + h := newApp(t) |
| + if rec := store(t, h, "", " "); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("empty store: status %d, want 303", rec.Code) |
| + } |
| + if !strings.Contains(get(t, h, "/?err=empty").Body.String(), "nothing to store") { |
| + t.Error("the empty-submission page does not say what went wrong") |
| + } |
| +} |
| + |
| +// The footer states the no-egress property on every screen. It is the |
| +// product's main honest claim in slice 0, so it is worth a test. |
| +func TestFooterStatesNoEgress(t *testing.T) { |
| + body := get(t, newApp(t), "/").Body.String() |
| + if !strings.Contains(body, "nothing leaves this instance") { |
| + t.Errorf("the page does not state the no-egress property:\n%s", body) |
| + } |
| +} |
diff --git a/tools/seed/main.go b/tools/seed/main.go| new file mode 100644 |
| index 0000000..9ac7dbf |
| --- /dev/null |
| +++ b/tools/seed/main.go |
| @@ -0,0 +1,117 @@ |
| +// Command seed ingests text files straight into a memoria database, |
| +// without going through HTTP. |
| +// |
| +// It exists so a fresh instance can be opened on a corpus that is already |
| +// worth searching, and it is the honest ancestor of slice 3's connectors: |
| +// same Store, same chunking, same sealing, no network. Nothing here |
| +// bypasses the trust model โ it needs the same DEK the app runs with, and |
| +// what it writes is sealed exactly as the app would seal it. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/base64" |
| + "flag" |
| + "fmt" |
| + "io/fs" |
| + "log/slog" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + |
| + memoria "amadan.net/oficina/memoria/internal/memoria" |
| + "amadan.net/oficina/memoria/internal/memoria/index" |
| + "amadan.net/oficina/memoria/internal/memoria/seal" |
| +) |
| + |
| +func main() { |
| + dbPath := flag.String("db", "", "path to the memoria database") |
| + root := flag.String("dir", "", "directory to walk for .md and .txt files") |
| + source := flag.String("source", "seed", "the Source recorded on each document") |
| + flag.Parse() |
| + if *dbPath == "" || *root == "" { |
| + fmt.Fprintln(os.Stderr, "usage: seed --db <path> --dir <path> [--source name]") |
| + os.Exit(2) |
| + } |
| + |
| + dekRaw := os.Getenv("MEMORIA_DEK") |
| + if dekRaw == "" { |
| + fmt.Fprintln(os.Stderr, "MEMORIA_DEK is not set; refusing to seed with a key the app cannot derive") |
| + os.Exit(1) |
| + } |
| + dek, err := base64.StdEncoding.DecodeString(dekRaw) |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "MEMORIA_DEK is not valid base64: %v\n", err) |
| + os.Exit(1) |
| + } |
| + sealer, err := seal.New(dek) |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "MEMORIA_DEK rejected: %v\n", err) |
| + os.Exit(1) |
| + } |
| + |
| + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) |
| + d, err := db.Open(*dbPath, logger) |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "open database: %v\n", err) |
| + os.Exit(1) |
| + } |
| + defer d.Close() |
| + |
| + ix, err := index.Open() |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "open index: %v\n", err) |
| + os.Exit(1) |
| + } |
| + defer ix.Close() |
| + |
| + store := memoria.NewStore(d, sealer, ix) |
| + ctx := context.Background() |
| + |
| + var n, skipped int |
| + err = filepath.WalkDir(*root, func(p string, e fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if e.IsDir() { |
| + // Anything under a dot-directory or node_modules is not the |
| + // team's writing. |
| + base := e.Name() |
| + if strings.HasPrefix(base, ".") || base == "node_modules" || base == "releases" { |
| + return fs.SkipDir |
| + } |
| + return nil |
| + } |
| + switch strings.ToLower(filepath.Ext(p)) { |
| + case ".md", ".txt", ".markdown": |
| + default: |
| + return nil |
| + } |
| + raw, err := os.ReadFile(p) |
| + if err != nil { |
| + return err |
| + } |
| + if len(strings.TrimSpace(string(raw))) == 0 { |
| + skipped++ |
| + return nil |
| + } |
| + rel, _ := filepath.Rel(*root, p) |
| + title := rel |
| + if _, err := store.Ingest(ctx, *source, title, string(raw)); err != nil { |
| + fmt.Fprintf(os.Stderr, "skip %s: %v\n", rel, err) |
| + skipped++ |
| + return nil |
| + } |
| + n++ |
| + fmt.Printf("seeded %s\n", rel) |
| + return nil |
| + }) |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "walk: %v\n", err) |
| + os.Exit(1) |
| + } |
| + count, _ := store.Count() |
| + fmt.Printf("\n%d documents seeded, %d skipped, %d passages indexed\n", n, skipped, count) |
| +} |