From 2ae9cd9e6ec284387cbf3de4841a9063d78a86da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:51:22 +0000 Subject: [PATCH 1/3] Initial plan From d0872d484f280b20dc0adf63d3d72b9bdf6484d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:05:32 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20embedded=20?= =?UTF-8?q?store=20+=20context=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/store package: SQLite (modernc.org/sqlite), schema migrations, CRUD operations for events/emails/todos/contacts, cosine similarity vector search, and Embedder interface. - Add SearchSummaries() and SaveInteraction() for engine.ContextSearcher. - Add Embed(), GenerateWithContext(), ChatWithContext() to engine. - Add ContextSearcher interface to engine (no circular dependency). - Update internal/config: add EmbeddingModel, DBPath fields. - Wire store context injection into ask/chat commands. - Implement learn command (re-index events missing embeddings). - Unit tests: 12 tests covering all CRUD, vector search, interface methods. Agent-Logs-Url: https://github.com/Kritarth-Dandapat/super-ollama/sessions/a33144a1-c45f-4fe3-84de-acf2a6756c28 Co-authored-by: Kritarth-Dandapat <141005022+Kritarth-Dandapat@users.noreply.github.com> --- cmd/super-ollama/main.go | 180 +++++++++++++++++- go.mod | 7 + go.sum | 14 ++ internal/config/config.go | 42 ++++- internal/engine/engine.go | 121 ++++++++++++ internal/store/store.go | 357 +++++++++++++++++++++++++++++++++++ internal/store/store_test.go | 356 ++++++++++++++++++++++++++++++++++ internal/store/types.go | 50 +++++ internal/store/vector.go | 64 +++++++ 9 files changed, 1181 insertions(+), 10 deletions(-) create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/types.go create mode 100644 internal/store/vector.go diff --git a/cmd/super-ollama/main.go b/cmd/super-ollama/main.go index 3dc36df4b7b..0458adbc6bd 100644 --- a/cmd/super-ollama/main.go +++ b/cmd/super-ollama/main.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "os/signal" + "path/filepath" "strings" "syscall" @@ -18,6 +19,7 @@ import ( "github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/internal/config" "github.com/ollama/ollama/internal/engine" + "github.com/ollama/ollama/internal/store" "github.com/ollama/ollama/internal/ui" "github.com/ollama/ollama/logutil" "github.com/ollama/ollama/runner" @@ -51,7 +53,7 @@ func main() { newStubCmd("email", "Email assistant (coming in a later phase)"), newStubCmd("todo", "TODO manager (coming in a later phase)"), newStubCmd("snap", "Screenshot capture (coming in a later phase)"), - newStubCmd("learn", "Learning-loop re-index (coming in a later phase)"), + newLearnCmd(), newConfigShowCmd(), ) @@ -123,7 +125,21 @@ func newAskCmd() *cobra.Command { } defer eng.Close() - out, err := eng.Generate(ctx, model, prompt, nil) + st, embModel, storeErr := openStore(eng) + if storeErr != nil { + slog.Warn("store unavailable; running without context injection", "err", storeErr) + } + if st != nil { + defer st.Close() + } + + var cs engine.ContextSearcher + if st != nil { + st.SetEmbedder(eng, embModel) + cs = st + } + + out, err := eng.GenerateWithContext(ctx, model, prompt, prompt, nil, cs) if err != nil { return err } @@ -152,6 +168,20 @@ func newChatCmd() *cobra.Command { } defer eng.Close() + st, embModel, storeErr := openStore(eng) + if storeErr != nil { + slog.Warn("store unavailable; running without context injection", "err", storeErr) + } + if st != nil { + defer st.Close() + st.SetEmbedder(eng, embModel) + } + + var cs engine.ContextSearcher + if st != nil { + cs = st + } + var msgs []api.Message scanner := bufio.NewScanner(os.Stdin) ui.Printf("Model: %s — type /bye to exit\n", model) @@ -172,7 +202,17 @@ func newChatCmd() *cobra.Command { ch := make(chan string, 64) errCh := make(chan error, 1) go func() { - errCh <- eng.StreamChat(ctx, model, msgs, nil, ch) + // StreamChat does not support context injection; inject context + // into the message list before streaming. + msgsToSend := msgs + if cs != nil { + summaries, serr := cs.SearchSummaries(ctx, line, 10) + if serr == nil && len(summaries) > 0 { + sysContent := buildContextMessage(summaries) + msgsToSend = append([]api.Message{{Role: "system", Content: sysContent}}, msgs...) + } + } + errCh <- eng.StreamChat(ctx, model, msgsToSend, nil, ch) }() var reply strings.Builder @@ -186,6 +226,11 @@ func newChatCmd() *cobra.Command { } ui.Println() msgs = append(msgs, api.Message{Role: "assistant", Content: reply.String()}) + + // Best-effort: save the interaction to the store. + if cs != nil { + _ = cs.SaveInteraction(ctx, line, reply.String()) + } } if err := scanner.Err(); err != nil { return err @@ -195,6 +240,18 @@ func newChatCmd() *cobra.Command { } } +// buildContextMessage formats retrieved summaries as a system message string. +func buildContextMessage(summaries []string) string { + var b strings.Builder + b.WriteString("Relevant context from your personal knowledge base:\n\n") + for i, s := range summaries { + if s != "" { + fmt.Fprintf(&b, "%d. %s\n", i+1, s) + } + } + return b.String() +} + func newStubCmd(name, short string) *cobra.Command { return &cobra.Command{ Use: name, @@ -205,6 +262,113 @@ func newStubCmd(name, short string) *cobra.Command { } } +// openStore opens the embedded SQLite store using the current config. +// It returns (nil, "", err) on any failure so callers can degrade gracefully. +func openStore(_ *engine.Engine) (*store.Store, string, error) { + cfg, err := config.Load() + if err != nil { + return nil, "", fmt.Errorf("load config: %w", err) + } + dbPath, err := config.ResolvedDBPath(cfg) + if err != nil { + return nil, "", fmt.Errorf("resolve db path: %w", err) + } + // Ensure the parent directory exists. + dir := filepath.Dir(dbPath) + if dir != "" && dir != "." { + if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil { + slog.Warn("could not create store directory", "dir", dir, "err", mkErr) + } + } + st, err := store.Open(dbPath, nil, "") + if err != nil { + return nil, "", fmt.Errorf("open store: %w", err) + } + // Embedder is wired by the caller via st.SetEmbedder after the engine is ready. + return st, cfg.EmbeddingModel, nil +} + +// newLearnCmd returns the `learn` command that re-indexes events missing embeddings. +func newLearnCmd() *cobra.Command { + return &cobra.Command{ + Use: "learn", + Short: "Re-index events that are missing embeddings", + Long: `learn iterates over every event in the local store that has no embedding and +generates one using the configured embedding model. Run this after restoring a +backup or first install to bring the semantic search index up to date.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + cfg, err := config.Load() + if err != nil { + return err + } + dbPath, err := config.ResolvedDBPath(cfg) + if err != nil { + return err + } + + st, err := store.Open(dbPath, nil, "") + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer st.Close() + + missing, err := st.EventsWithoutEmbedding(ctx) + if err != nil { + return err + } + if len(missing) == 0 { + ui.Println("All events already have embeddings. Nothing to do.") + return nil + } + ui.Printf("Found %d event(s) without embeddings. Starting engine…\n", len(missing)) + + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + st.SetEmbedder(eng, cfg.EmbeddingModel) + + var indexed, failed int + for _, ev := range missing { + select { + case <-ctx.Done(): + ui.Printf("Interrupted. Indexed %d/%d events.\n", indexed, len(missing)) + return ctx.Err() + default: + } + + text := ev.Summary + if text == "" { + text = ev.RawText + } + if text == "" { + continue + } + + emb, embErr := eng.Embed(ctx, cfg.EmbeddingModel, text) + if embErr != nil { + slog.Warn("embed failed", "event_id", ev.ID, "err", embErr) + failed++ + continue + } + if updateErr := st.UpdateEventEmbedding(ctx, ev.ID, emb); updateErr != nil { + slog.Warn("update embedding failed", "event_id", ev.ID, "err", updateErr) + failed++ + continue + } + indexed++ + } + ui.Printf("Done. Indexed %d event(s). Failed: %d.\n", indexed, failed) + return nil + }, + } +} + func newConfigShowCmd() *cobra.Command { return &cobra.Command{ Use: "config", @@ -218,8 +382,14 @@ func newConfigShowCmd() *cobra.Command { if err != nil { return err } - ui.Printf("config file: %s\n", path) - ui.Printf("default_model: %s\n", cfg.DefaultModel) + dbPath, err := config.ResolvedDBPath(cfg) + if err != nil { + return err + } + ui.Printf("config file: %s\n", path) + ui.Printf("default_model: %s\n", cfg.DefaultModel) + ui.Printf("embedding_model: %s\n", cfg.EmbeddingModel) + ui.Printf("db_path: %s\n", dbPath) if strings.TrimSpace(modelFlag) != "" { ui.Printf("active --model override: %s\n", strings.TrimSpace(modelFlag)) } diff --git a/go.mod b/go.mod index 6949b147108..e55c6451ec2 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v24.3.25+incompatible // indirect @@ -64,8 +65,10 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/set v1.0.0 // indirect @@ -73,6 +76,10 @@ require ( golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gorgonia.org/vecf32 v0.9.0 // indirect gorgonia.org/vecf64 v0.9.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.34.5 // indirect ) require ( diff --git a/go.sum b/go.sum index 4074dd1860f..11f6b7f9cc0 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -186,6 +188,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nlpodyssey/gopickle v0.3.0 h1:BLUE5gxFLyyNOPzlXxt6GoHEMMxD0qhsE4p0CIQyoLw= github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -206,6 +210,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -442,5 +448,13 @@ gorgonia.org/vecf64 v0.9.0 h1:bgZDP5x0OzBF64PjMGC3EvTdOoMEcmfAh1VCUnZFm1A= gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go index 1e6dbe87c30..1e04b2378c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,14 +8,32 @@ import ( "github.com/pelletier/go-toml/v2" ) -const defaultModel = "gemma3:1b" +const ( + defaultModel = "gemma3:1b" + defaultEmbeddingModel = "nomic-embed-text" + defaultDBPath = "~/.super-ollama/data.db" +) // Config holds super-ollama runtime settings from config.toml. type Config struct { - DefaultModel string `toml:"default_model"` + DefaultModel string `toml:"default_model"` + EmbeddingModel string `toml:"embedding_model"` + DBPath string `toml:"db_path"` +} + +// ResolvedDBPath expands ~ in the db_path and returns an absolute path. +func ResolvedDBPath(cfg Config) (string, error) { + p := cfg.DBPath + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + p = filepath.Join(home, p[2:]) + } + return filepath.Abs(p) } -// ResolvedPath returns the path to the active config file. func ResolvedPath() (string, error) { if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { return filepath.Join(xdg, "super-ollama", "config.toml"), nil @@ -31,12 +49,20 @@ func ResolvedPath() (string, error) { func Load() (Config, error) { path, err := ResolvedPath() if err != nil { - return Config{DefaultModel: defaultModel}, err + return Config{ + DefaultModel: defaultModel, + EmbeddingModel: defaultEmbeddingModel, + DBPath: defaultDBPath, + }, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return Config{DefaultModel: defaultModel}, nil + return Config{ + DefaultModel: defaultModel, + EmbeddingModel: defaultEmbeddingModel, + DBPath: defaultDBPath, + }, nil } return Config{}, err } @@ -47,5 +73,11 @@ func Load() (Config, error) { if strings.TrimSpace(c.DefaultModel) == "" { c.DefaultModel = defaultModel } + if strings.TrimSpace(c.EmbeddingModel) == "" { + c.EmbeddingModel = defaultEmbeddingModel + } + if strings.TrimSpace(c.DBPath) == "" { + c.DBPath = defaultDBPath + } return c, nil } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3bf55adadec..74e444c4d79 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -193,6 +193,127 @@ func (e *Engine) StreamChat(ctx context.Context, modelName string, messages []ap return nil } +// Embed generates an embedding vector for text using the given model. +// It calls the in-process EmbedHandler, matching how Generate works. +func (e *Engine) Embed(ctx context.Context, model, text string) ([]float32, error) { + req := api.EmbedRequest{ + Model: model, + Input: text, + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/embed", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + e.srv.EmbedHandler(c) + if w.Code != http.StatusOK { + return nil, httpErrorFromRecorder(w) + } + var resp api.EmbedResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + return nil, err + } + if len(resp.Embeddings) == 0 { + return nil, fmt.Errorf("embed: no embeddings returned") + } + return resp.Embeddings[0], nil +} + +// GenerateWithContext is like Generate but prepends semantically similar events +// from the store as context when st is non-nil and contextQuery is non-empty. +// After a successful generation, the (prompt, response) pair is saved as an +// interaction event (best-effort; errors are silently dropped). +func (e *Engine) GenerateWithContext( + ctx context.Context, + modelName, prompt, contextQuery string, + opts Options, + st ContextSearcher, +) (string, error) { + finalPrompt := prompt + if st != nil && contextQuery != "" { + if injected := injectContext(ctx, st, contextQuery, prompt); injected != "" { + finalPrompt = injected + } + } + resp, err := e.Generate(ctx, modelName, finalPrompt, opts) + if err != nil { + return "", err + } + if st != nil { + _ = st.SaveInteraction(ctx, prompt, resp) // best-effort + } + return resp, nil +} + +// ChatWithContext is like Chat but prepends semantically similar events as a +// system message when st is non-nil and contextQuery is non-empty. +func (e *Engine) ChatWithContext( + ctx context.Context, + modelName string, + messages []api.Message, + contextQuery string, + opts Options, + st ContextSearcher, +) (api.Message, error) { + msgs := messages + if st != nil && contextQuery != "" { + if content := searchContextContent(ctx, st, contextQuery); content != "" { + systemMsg := api.Message{Role: "system", Content: content} + msgs = append([]api.Message{systemMsg}, messages...) + } + } + return e.Chat(ctx, modelName, msgs, opts) +} + +// ContextSearcher is the minimal interface the engine requires from the store. +// This avoids a hard import dependency on internal/store from within engine, +// and keeps the two packages independently testable. +type ContextSearcher interface { + // SearchSummaries returns the top-k event summaries most similar to query. + SearchSummaries(ctx context.Context, query string, topK int) ([]string, error) + // SaveInteraction persists a (prompt, response) pair for future retrieval. + SaveInteraction(ctx context.Context, prompt, response string) error +} + +// injectContext retrieves context from the store and builds an augmented prompt. +func injectContext(ctx context.Context, st ContextSearcher, query, prompt string) string { + summaries, err := st.SearchSummaries(ctx, query, 10) + if err != nil || len(summaries) == 0 { + return "" + } + var b bytes.Buffer + b.WriteString("Relevant context from your personal knowledge base:\n\n") + for i, s := range summaries { + if s == "" { + continue + } + fmt.Fprintf(&b, "%d. %s\n", i+1, s) + } + b.WriteString("\n---\n\n") + b.WriteString(prompt) + return b.String() +} + +// searchContextContent returns a system-message string with retrieved summaries. +func searchContextContent(ctx context.Context, st ContextSearcher, query string) string { + summaries, err := st.SearchSummaries(ctx, query, 10) + if err != nil || len(summaries) == 0 { + return "" + } + var b bytes.Buffer + b.WriteString("Relevant context from your personal knowledge base:\n\n") + for i, s := range summaries { + if s == "" { + continue + } + fmt.Fprintf(&b, "%d. %s\n", i+1, s) + } + return b.String() +} + func httpErrorFromRecorder(w *httptest.ResponseRecorder) error { var body struct { Error string `json:"error"` diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 00000000000..dbccbfea35a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,357 @@ +// Package store provides an embedded SQLite data store with semantic vector +// search for super-ollama. All tables are created (migrated) automatically on +// Open so no separate migration step is required at runtime. +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + _ "modernc.org/sqlite" // pure-Go SQLite driver (no CGO) +) + +// Embedder generates a float32 embedding vector for the given text. +type Embedder interface { + Embed(ctx context.Context, model, text string) ([]float32, error) +} + +// Store wraps a SQLite database and an optional embedder for semantic search. +type Store struct { + db *sql.DB + embedder Embedder + embModel string // embedding model name, e.g. "nomic-embed-text" +} + +const schema = ` +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + source TEXT NOT NULL, + raw_text TEXT, + summary TEXT, + tags TEXT, + embedding BLOB +); + +CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE, + subject TEXT, + from_addr TEXT, + to_addr TEXT, + date INTEGER, + body TEXT, + summary TEXT, + embedding BLOB +); + +CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at INTEGER, + done_at INTEGER, + priority TEXT, + text TEXT, + tags TEXT +); + +CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE, + name TEXT, + notes TEXT, + embedding BLOB +); +` + +// Open opens (or creates) the SQLite database at dbPath and runs schema +// migrations. Pass ":memory:" for an in-memory database. +// embedder and embModel are optional; when set, they power semantic Search. +func Open(dbPath string, embedder Embedder, embModel string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("store: open db: %w", err) + } + // Enable WAL for better concurrent read performance. + if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil { + db.Close() + return nil, fmt.Errorf("store: wal pragma: %w", err) + } + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("store: migrate schema: %w", err) + } + return &Store{db: db, embedder: embedder, embModel: embModel}, nil +} + +// SetEmbedder configures the Embedder and embedding model name used by Search. +// Call this after Open when the embedder (e.g. the engine) is available. +func (s *Store) SetEmbedder(embedder Embedder, model string) { + s.embedder = embedder + s.embModel = model +} + +// Close closes the underlying database. +func (s *Store) Close() error { + return s.db.Close() +} + +// ──────────────────────────────────────────────────────────────────────────── +// Events +// ──────────────────────────────────────────────────────────────────────────── + +// InsertEvent persists a new event. The ID and TS fields are set automatically +// when they are zero. +func (s *Store) InsertEvent(ctx context.Context, ev *Event) error { + if ev.TS == 0 { + ev.TS = time.Now().UnixMilli() + } + embBlob, err := marshalEmbedding(ev.Embedding) + if err != nil { + return err + } + res, err := s.db.ExecContext(ctx, + `INSERT INTO events (ts, source, raw_text, summary, tags, embedding) + VALUES (?, ?, ?, ?, ?, ?)`, + ev.TS, ev.Source, ev.RawText, ev.Summary, ev.Tags, embBlob) + if err != nil { + return fmt.Errorf("store: insert event: %w", err) + } + ev.ID, _ = res.LastInsertId() + return nil +} + +// ListEvents returns all events ordered by timestamp descending. +func (s *Store) ListEvents(ctx context.Context) ([]Event, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, ts, source, raw_text, summary, tags, embedding FROM events ORDER BY ts DESC`) + if err != nil { + return nil, fmt.Errorf("store: list events: %w", err) + } + defer rows.Close() + return scanEvents(rows) +} + +// EventsWithoutEmbedding returns events whose embedding column is NULL or empty. +func (s *Store) EventsWithoutEmbedding(ctx context.Context) ([]Event, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, ts, source, raw_text, summary, tags, embedding FROM events + WHERE embedding IS NULL OR embedding = ''`) + if err != nil { + return nil, fmt.Errorf("store: events without embedding: %w", err) + } + defer rows.Close() + return scanEvents(rows) +} + +// UpdateEventEmbedding saves a computed embedding back to an existing event row. +func (s *Store) UpdateEventEmbedding(ctx context.Context, id int64, emb []float32) error { + blob, err := marshalEmbedding(emb) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `UPDATE events SET embedding = ? WHERE id = ?`, blob, id) + if err != nil { + return fmt.Errorf("store: update event embedding: %w", err) + } + return nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// Emails +// ──────────────────────────────────────────────────────────────────────────── + +// InsertEmail persists an email record. On conflict (same message_id) it is +// replaced. +func (s *Store) InsertEmail(ctx context.Context, em *Email) error { + embBlob, err := marshalEmbedding(em.Embedding) + if err != nil { + return err + } + res, err := s.db.ExecContext(ctx, + `INSERT OR REPLACE INTO emails + (message_id, subject, from_addr, to_addr, date, body, summary, embedding) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + em.MessageID, em.Subject, em.FromAddr, em.ToAddr, + em.Date, em.Body, em.Summary, embBlob) + if err != nil { + return fmt.Errorf("store: insert email: %w", err) + } + em.ID, _ = res.LastInsertId() + return nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// Todos +// ──────────────────────────────────────────────────────────────────────────── + +// UpsertTodo inserts or replaces a todo by its primary key. +func (s *Store) UpsertTodo(ctx context.Context, t *Todo) error { + if t.CreatedAt == 0 { + t.CreatedAt = time.Now().UnixMilli() + } + var doneAt *int64 + if t.DoneAt != 0 { + doneAt = &t.DoneAt + } + res, err := s.db.ExecContext(ctx, + `INSERT OR REPLACE INTO todos (id, created_at, done_at, priority, text, tags) + VALUES (?, ?, ?, ?, ?, ?)`, + nullableID(t.ID), t.CreatedAt, doneAt, t.Priority, t.Text, t.Tags) + if err != nil { + return fmt.Errorf("store: upsert todo: %w", err) + } + if t.ID == 0 { + t.ID, _ = res.LastInsertId() + } + return nil +} + +// ListTodos returns all todo items. +func (s *Store) ListTodos(ctx context.Context) ([]Todo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, created_at, done_at, priority, text, tags FROM todos ORDER BY created_at`) + if err != nil { + return nil, fmt.Errorf("store: list todos: %w", err) + } + defer rows.Close() + var todos []Todo + for rows.Next() { + var t Todo + var doneAt sql.NullInt64 + if err := rows.Scan(&t.ID, &t.CreatedAt, &doneAt, &t.Priority, &t.Text, &t.Tags); err != nil { + return nil, err + } + if doneAt.Valid { + t.DoneAt = doneAt.Int64 + } + todos = append(todos, t) + } + return todos, rows.Err() +} + +// ──────────────────────────────────────────────────────────────────────────── +// Contacts +// ──────────────────────────────────────────────────────────────────────────── + +// UpsertContact inserts or replaces a contact by email address. +func (s *Store) UpsertContact(ctx context.Context, c *Contact) error { + embBlob, err := marshalEmbedding(c.Embedding) + if err != nil { + return err + } + res, err := s.db.ExecContext(ctx, + `INSERT OR REPLACE INTO contacts (id, email, name, notes, embedding) + VALUES (?, ?, ?, ?, ?)`, + nullableID(c.ID), c.Email, c.Name, c.Notes, embBlob) + if err != nil { + return fmt.Errorf("store: upsert contact: %w", err) + } + if c.ID == 0 { + c.ID, _ = res.LastInsertId() + } + return nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// Semantic search +// ──────────────────────────────────────────────────────────────────────────── + +// Search embeds query using the configured Embedder, loads all event embeddings +// from SQLite, computes cosine similarity and returns the topK most similar +// events. Requires an Embedder to have been provided to Open. +func (s *Store) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) { + if s.embedder == nil { + return nil, fmt.Errorf("store: no embedder configured") + } + queryEmb, err := s.embedder.Embed(ctx, s.embModel, query) + if err != nil { + return nil, fmt.Errorf("store: embed query: %w", err) + } + events, err := s.ListEvents(ctx) + if err != nil { + return nil, err + } + results := rankBySimilarity(queryEmb, events, topK) + return results, nil +} + +// SearchSummaries is a convenience wrapper over Search that returns just the +// Summary strings of the top-k matching events. It satisfies the +// engine.ContextSearcher interface. +func (s *Store) SearchSummaries(ctx context.Context, query string, topK int) ([]string, error) { + results, err := s.Search(ctx, query, topK) + if err != nil { + return nil, err + } + summaries := make([]string, 0, len(results)) + for _, r := range results { + if r.Event != nil && r.Event.Summary != "" { + summaries = append(summaries, r.Event.Summary) + } + } + return summaries, nil +} + +// SaveInteraction persists a prompt/response pair as an Event with +// source='interaction'. It satisfies the engine.ContextSearcher interface. +func (s *Store) SaveInteraction(ctx context.Context, prompt, response string) error { + ev := &Event{ + Source: "interaction", + RawText: prompt, + Summary: response, + } + return s.InsertEvent(ctx, ev) +} + + +func marshalEmbedding(emb []float32) ([]byte, error) { + if len(emb) == 0 { + return nil, nil + } + b, err := json.Marshal(emb) + if err != nil { + return nil, fmt.Errorf("store: marshal embedding: %w", err) + } + return b, nil +} + +func unmarshalEmbedding(data []byte) ([]float32, error) { + if len(data) == 0 { + return nil, nil + } + var emb []float32 + if err := json.Unmarshal(data, &emb); err != nil { + return nil, fmt.Errorf("store: unmarshal embedding: %w", err) + } + return emb, nil +} + +func scanEvents(rows *sql.Rows) ([]Event, error) { + var events []Event + for rows.Next() { + var ev Event + var embBlob []byte + if err := rows.Scan(&ev.ID, &ev.TS, &ev.Source, &ev.RawText, &ev.Summary, &ev.Tags, &embBlob); err != nil { + return nil, err + } + emb, err := unmarshalEmbedding(embBlob) + if err != nil { + return nil, err + } + ev.Embedding = emb + events = append(events, ev) + } + return events, rows.Err() +} + +// nullableID returns nil when id is 0 so that SQLite auto-assigns a new ROWID. +func nullableID(id int64) any { + if id == 0 { + return nil + } + return id +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 00000000000..b8d13e136cc --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,356 @@ +package store_test + +import ( + "context" + "math" + "testing" + + "github.com/ollama/ollama/internal/store" +) + +// ── helpers ──────────────────────────────────────────────────────────────── + +func openMem(t *testing.T) *store.Store { + t.Helper() + s, err := store.Open(":memory:", nil, "") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +// mockEmbedder maps strings to fixed vectors for testing. +type mockEmbedder map[string][]float32 + +func (m mockEmbedder) Embed(_ context.Context, _, text string) ([]float32, error) { + if v, ok := m[text]; ok { + return v, nil + } + // Return a zero vector for unknown inputs. + return []float32{0, 0, 1}, nil +} + +// ── schema / migration ───────────────────────────────────────────────────── + +func TestOpen_InMemory(t *testing.T) { + s := openMem(t) + if s == nil { + t.Fatal("expected non-nil store") + } +} + +// ── events ───────────────────────────────────────────────────────────────── + +func TestInsertAndListEvents(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + ev := &store.Event{ + Source: "manual", + RawText: "hello world", + Summary: "greeting", + Tags: `["test"]`, + } + if err := s.InsertEvent(ctx, ev); err != nil { + t.Fatalf("InsertEvent: %v", err) + } + if ev.ID == 0 { + t.Fatal("expected non-zero ID after insert") + } + if ev.TS == 0 { + t.Fatal("expected TS to be populated") + } + + events, err := s.ListEvents(ctx) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Summary != "greeting" { + t.Errorf("unexpected summary: %q", events[0].Summary) + } +} + +func TestEventsWithoutEmbedding(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + // Insert one without embedding, one with. + ev1 := &store.Event{Source: "manual", Summary: "no embedding"} + if err := s.InsertEvent(ctx, ev1); err != nil { + t.Fatal(err) + } + ev2 := &store.Event{Source: "manual", Summary: "has embedding", Embedding: []float32{0.1, 0.2}} + if err := s.InsertEvent(ctx, ev2); err != nil { + t.Fatal(err) + } + + missing, err := s.EventsWithoutEmbedding(ctx) + if err != nil { + t.Fatal(err) + } + if len(missing) != 1 { + t.Fatalf("expected 1 event without embedding, got %d", len(missing)) + } + if missing[0].ID != ev1.ID { + t.Errorf("wrong event returned: %d", missing[0].ID) + } +} + +func TestUpdateEventEmbedding(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + ev := &store.Event{Source: "manual", Summary: "to update"} + if err := s.InsertEvent(ctx, ev); err != nil { + t.Fatal(err) + } + + emb := []float32{1.0, 0.0, 0.0} + if err := s.UpdateEventEmbedding(ctx, ev.ID, emb); err != nil { + t.Fatalf("UpdateEventEmbedding: %v", err) + } + + events, err := s.ListEvents(ctx) + if err != nil { + t.Fatal(err) + } + if len(events[0].Embedding) != 3 { + t.Fatalf("expected 3-dim embedding, got %d", len(events[0].Embedding)) + } +} + +// ── emails ───────────────────────────────────────────────────────────────── + +func TestInsertEmail(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + em := &store.Email{ + MessageID: "", + Subject: "Hello", + FromAddr: "alice@example.com", + ToAddr: "bob@example.com", + Date: 1234567890, + Body: "Hi Bob!", + Summary: "greeting from Alice", + } + if err := s.InsertEmail(ctx, em); err != nil { + t.Fatalf("InsertEmail: %v", err) + } + if em.ID == 0 { + t.Fatal("expected non-zero ID") + } + + // Upsert (same message_id) should replace, not error. + em.Subject = "Hello (updated)" + if err := s.InsertEmail(ctx, em); err != nil { + t.Fatalf("InsertEmail (upsert): %v", err) + } +} + +// ── todos ────────────────────────────────────────────────────────────────── + +func TestUpsertAndListTodos(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + todo := &store.Todo{ + Priority: "high", + Text: "finish the report", + Tags: `["work"]`, + } + if err := s.UpsertTodo(ctx, todo); err != nil { + t.Fatalf("UpsertTodo: %v", err) + } + if todo.ID == 0 { + t.Fatal("expected non-zero ID") + } + if todo.CreatedAt == 0 { + t.Fatal("expected CreatedAt to be set") + } + + todos, err := s.ListTodos(ctx) + if err != nil { + t.Fatalf("ListTodos: %v", err) + } + if len(todos) != 1 || todos[0].Text != "finish the report" { + t.Fatalf("unexpected todos: %+v", todos) + } +} + +// ── contacts ─────────────────────────────────────────────────────────────── + +func TestUpsertContact(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + c := &store.Contact{ + Email: "carol@example.com", + Name: "Carol", + Notes: "colleague", + } + if err := s.UpsertContact(ctx, c); err != nil { + t.Fatalf("UpsertContact: %v", err) + } + if c.ID == 0 { + t.Fatal("expected non-zero ID") + } + + // Re-upsert to test REPLACE. + c.Notes = "updated" + if err := s.UpsertContact(ctx, c); err != nil { + t.Fatalf("UpsertContact (update): %v", err) + } +} + +// ── vector search ────────────────────────────────────────────────────────── + +func TestSearch(t *testing.T) { + ctx := context.Background() + + embeddings := mockEmbedder{ + "query about cats": {1, 0, 0}, + "cats meowing": {0.9, 0.1, 0}, // similar to query + "dogs barking": {0, 1, 0}, // dissimilar + "cat napping": {0.95, 0.05, 0}, // very similar + } + s, err := store.Open(":memory:", embeddings, "test-model") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + for text, emb := range embeddings { + if text == "query about cats" { + continue // don't insert the query itself + } + ev := &store.Event{ + Source: "manual", + Summary: text, + Embedding: emb, + } + if err := s.InsertEvent(ctx, ev); err != nil { + t.Fatal(err) + } + } + + results, err := s.Search(ctx, "query about cats", 2) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + // The first result should be "cat napping" (highest cosine similarity). + if results[0].Event.Summary != "cat napping" { + t.Errorf("unexpected top result: %q", results[0].Event.Summary) + } + // Scores should be descending. + if results[0].Score < results[1].Score { + t.Errorf("results not sorted descending: %f vs %f", results[0].Score, results[1].Score) + } +} + +func TestSearchNoEmbedder(t *testing.T) { + ctx := context.Background() + s := openMem(t) + _, err := s.Search(ctx, "anything", 5) + if err == nil { + t.Fatal("expected error when no embedder configured") + } +} + +// ── cosine similarity ────────────────────────────────────────────────────── + +func TestCosineSimilarity_orthogonal(t *testing.T) { + // Test via Search with known orthogonal vectors. + ctx := context.Background() + emb := mockEmbedder{ + "q": {1, 0, 0}, + "a": {0, 1, 0}, + } + s, err := store.Open(":memory:", emb, "test") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.InsertEvent(ctx, &store.Event{ + Summary: "a", Source: "test", Embedding: []float32{0, 1, 0}, + }); err != nil { + t.Fatal(err) + } + + results, err := s.Search(ctx, "q", 1) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatal("expected 1 result") + } + // Orthogonal vectors should have near-zero cosine similarity. + if math.Abs(results[0].Score) > 1e-9 { + t.Errorf("expected ~0 cosine similarity, got %f", results[0].Score) + } +} + +// ── SearchSummaries ──────────────────────────────────────────────────────── + +func TestSearchSummaries(t *testing.T) { + ctx := context.Background() + embeddings := mockEmbedder{ + "query": {1, 0, 0}, + "result": {0.99, 0.01, 0}, + } + s, err := store.Open(":memory:", embeddings, "test") + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.InsertEvent(ctx, &store.Event{ + Source: "manual", Summary: "result", Embedding: []float32{0.99, 0.01, 0}, + }); err != nil { + t.Fatal(err) + } + + summaries, err := s.SearchSummaries(ctx, "query", 5) + if err != nil { + t.Fatalf("SearchSummaries: %v", err) + } + if len(summaries) != 1 || summaries[0] != "result" { + t.Errorf("unexpected summaries: %v", summaries) + } +} + +// ── SaveInteraction ──────────────────────────────────────────────────────── + +func TestSaveInteraction(t *testing.T) { + ctx := context.Background() + s := openMem(t) + + if err := s.SaveInteraction(ctx, "hello world", "hi there"); err != nil { + t.Fatalf("SaveInteraction: %v", err) + } + + events, err := s.ListEvents(ctx) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Source != "interaction" { + t.Errorf("unexpected source: %q", events[0].Source) + } + if events[0].RawText != "hello world" { + t.Errorf("unexpected raw_text: %q", events[0].RawText) + } + if events[0].Summary != "hi there" { + t.Errorf("unexpected summary: %q", events[0].Summary) + } +} diff --git a/internal/store/types.go b/internal/store/types.go new file mode 100644 index 00000000000..752b9144045 --- /dev/null +++ b/internal/store/types.go @@ -0,0 +1,50 @@ +package store + +// Event represents a timestamped occurrence captured from any source. +type Event struct { + ID int64 + TS int64 // unix epoch ms + Source string // 'screenshot' | 'email' | 'todo' | 'manual' | 'interaction' + RawText string + Summary string + Tags string // JSON array + Embedding []float32 +} + +// Email represents an indexed email message. +type Email struct { + ID int64 + MessageID string + Subject string + FromAddr string + ToAddr string + Date int64 + Body string + Summary string + Embedding []float32 +} + +// Todo represents a task item. +type Todo struct { + ID int64 + CreatedAt int64 + DoneAt int64 + Priority string + Text string + Tags string // JSON array +} + +// Contact represents a person in the contacts store. +type Contact struct { + ID int64 + Email string + Name string + Notes string + Embedding []float32 +} + +// SearchResult is a ranked result from a semantic search. +type SearchResult struct { + Event *Event + Score float64 +} diff --git a/internal/store/vector.go b/internal/store/vector.go new file mode 100644 index 00000000000..436fda69787 --- /dev/null +++ b/internal/store/vector.go @@ -0,0 +1,64 @@ +package store + +import ( + "math" + "sort" +) + +// rankBySimilarity returns the topK events ranked by cosine similarity to queryEmb. +// Events with no embedding are skipped. +func rankBySimilarity(queryEmb []float32, events []Event, topK int) []SearchResult { + if len(queryEmb) == 0 || len(events) == 0 { + return nil + } + + type scored struct { + ev Event + score float64 + } + + var candidates []scored + for _, ev := range events { + if len(ev.Embedding) == 0 { + continue + } + s := cosineSimilarity(queryEmb, ev.Embedding) + candidates = append(candidates, scored{ev: ev, score: s}) + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].score > candidates[j].score + }) + + if topK > 0 && len(candidates) > topK { + candidates = candidates[:topK] + } + + results := make([]SearchResult, 0, len(candidates)) + for _, c := range candidates { + ev := c.ev + results = append(results, SearchResult{Event: &ev, Score: c.score}) + } + return results +} + +// cosineSimilarity computes the cosine similarity between two float32 vectors. +// Returns 0 if either vector has zero magnitude. +func cosineSimilarity(a, b []float32) float64 { + n := len(a) + if len(b) < n { + n = len(b) + } + var dot, magA, magB float64 + for i := range n { + ai := float64(a[i]) + bi := float64(b[i]) + dot += ai * bi + magA += ai * ai + magB += bi * bi + } + if magA == 0 || magB == 0 { + return 0 + } + return dot / (math.Sqrt(magA) * math.Sqrt(magB)) +} From bc25e343e841f2993994a91da3f223c8a4ddaa06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:09:14 +0000 Subject: [PATCH 3/3] Phase 2: embedded store + context injection Agent-Logs-Url: https://github.com/Kritarth-Dandapat/super-ollama/sessions/a33144a1-c45f-4fe3-84de-acf2a6756c28 Co-authored-by: Kritarth-Dandapat <141005022+Kritarth-Dandapat@users.noreply.github.com> --- go.mod | 2 +- go.sum | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e55c6451ec2..34dcfce003f 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( golang.org/x/mod v0.30.0 golang.org/x/tools v0.38.0 gonum.org/v1/gonum v0.15.0 + modernc.org/sqlite v1.34.5 ) require ( @@ -79,7 +80,6 @@ require ( modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.34.5 // indirect ) require ( diff --git a/go.sum b/go.sum index 11f6b7f9cc0..bf3531c47d3 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -448,13 +450,29 @@ gorgonia.org/vecf64 v0.9.0 h1:bgZDP5x0OzBF64PjMGC3EvTdOoMEcmfAh1VCUnZFm1A= gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=