From 54744cb82f32349d1b74fd6a6c1075235c6cd721 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:29 +0000 Subject: [PATCH 1/3] Initial plan From 8b3d2de886ec0d37dec48e4df4230c991848e54f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:55:07 +0000 Subject: [PATCH 2/3] Initial plan for Phase 3: screen capture + indexing Agent-Logs-Url: https://github.com/Kritarth-Dandapat/super-ollama/sessions/758aabf0-5938-4cd9-969c-649bc0176283 Co-authored-by: Kritarth-Dandapat <141005022+Kritarth-Dandapat@users.noreply.github.com> --- go.mod | 5 +++++ go.sum | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/go.mod b/go.mod index 6949b147108..886c55e8006 100644 --- a/go.mod +++ b/go.mod @@ -54,10 +54,14 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v24.3.25+incompatible // indirect + github.com/jezek/xgb v1.1.0 // indirect + github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-pointer v0.0.1 // indirect @@ -67,6 +71,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/set v1.0.0 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect diff --git a/go.sum b/go.sum index 4074dd1860f..6e757dc149e 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 h1:VLEKvjGJYAMCXw0/32r9io61tEXnMWDRxMk+peyRVFc= +github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= @@ -140,11 +142,15 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= +github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q= +github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= @@ -162,6 +168,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -209,6 +217,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: 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= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= @@ -349,6 +359,7 @@ golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From 90f4b01ea45c1899713d6a3194ffa086f57f3535 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:11 +0000 Subject: [PATCH 3/3] Phase 3: screen capture + indexing implementation Agent-Logs-Url: https://github.com/Kritarth-Dandapat/super-ollama/sessions/758aabf0-5938-4cd9-969c-649bc0176283 Co-authored-by: Kritarth-Dandapat <141005022+Kritarth-Dandapat@users.noreply.github.com> --- cmd/super-ollama/main.go | 64 +++++++++++++- go.mod | 4 +- internal/capture/capture.go | 135 +++++++++++++++++++++++++++++ internal/capture/capture_test.go | 142 +++++++++++++++++++++++++++++++ internal/capture/privacy.go | 96 +++++++++++++++++++++ internal/capture/scheduler.go | 36 ++++++++ internal/config/config.go | 11 ++- internal/config/config_test.go | 81 ++++++++++++++++++ internal/store/store.go | 86 +++++++++++++++++++ internal/store/store_test.go | 82 ++++++++++++++++++ 10 files changed, 731 insertions(+), 6 deletions(-) create mode 100644 internal/capture/capture.go create mode 100644 internal/capture/capture_test.go create mode 100644 internal/capture/privacy.go create mode 100644 internal/capture/scheduler.go create mode 100644 internal/config/config_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/cmd/super-ollama/main.go b/cmd/super-ollama/main.go index 3dc36df4b7b..8b12433a0c6 100644 --- a/cmd/super-ollama/main.go +++ b/cmd/super-ollama/main.go @@ -16,8 +16,10 @@ import ( "github.com/ollama/ollama/api" "github.com/ollama/ollama/envconfig" + "github.com/ollama/ollama/internal/capture" "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" @@ -50,7 +52,7 @@ func main() { newChatCmd(), 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)"), + newSnapCmd(), newStubCmd("learn", "Learning-loop re-index (coming in a later phase)"), newConfigShowCmd(), ) @@ -195,6 +197,66 @@ func newChatCmd() *cobra.Command { } } +func newSnapCmd() *cobra.Command { + return &cobra.Command{ + Use: "snap", + Short: "Capture all monitors, OCR, summarise, and index to the data store", + RunE: func(cmd *cobra.Command, args []string) error { + model, err := resolveModel() + if err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + dbPath, err := store.DefaultPath() + if err != nil { + return err + } + st, err := store.Open(dbPath) + if err != nil { + return err + } + defer st.Close() + + pf, err := capture.LoadPrivacyFilter() + if err != nil { + return fmt.Errorf("snap: load privacy filter: %w", err) + } + + generateFn := func(ctx context.Context, mdl, prompt string) (string, error) { + return eng.Generate(ctx, mdl, prompt, nil) + } + + _ = cfg // capture_interval_minutes is used by the daemon; snap is always one-shot + cap := capture.New(model, generateFn, st, pf) + summaries, err := cap.Snap(ctx) + if err != nil { + return err + } + if len(summaries) == 0 { + ui.Println("snap: no content captured (privacy filter matched or empty OCR)") + return nil + } + for i, s := range summaries { + ui.Printf("Display %d: %s\n", i+1, s) + } + return nil + }, + } +} + func newStubCmd(name, short string) *cobra.Command { return &cobra.Command{ Use: name, diff --git a/go.mod b/go.mod index 886c55e8006..c978a45f984 100644 --- a/go.mod +++ b/go.mod @@ -24,11 +24,13 @@ require ( github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1 github.com/dlclark/regexp2 v1.11.4 github.com/emirpasic/gods/v2 v2.0.0-alpha + github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 github.com/klauspost/compress v1.18.3 github.com/mattn/go-runewidth v0.0.16 github.com/nlpodyssey/gopickle v0.3.0 github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/robfig/cron/v3 v3.0.1 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-cpp v0.23.4 github.com/wk8/go-ordered-map/v2 v2.1.8 @@ -58,7 +60,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/jezek/xgb v1.1.0 // indirect - github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect @@ -71,7 +72,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/set v1.0.0 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect diff --git a/internal/capture/capture.go b/internal/capture/capture.go new file mode 100644 index 00000000000..d629efe7cc2 --- /dev/null +++ b/internal/capture/capture.go @@ -0,0 +1,135 @@ +// Package capture implements multi-monitor screenshot capture, OCR, LLM-based +// summarisation, and indexing to the data store. +package capture + +import ( + "context" + "errors" + "fmt" + "image" + "image/png" + "os" + "os/exec" + "strings" + + "github.com/kbinani/screenshot" + "github.com/ollama/ollama/internal/store" +) + +// GenerateFunc is the function signature used to call the LLM for summarisation. +type GenerateFunc func(ctx context.Context, model, prompt string) (string, error) + +// Capturer manages screen capture, OCR, summarisation, and indexing. +type Capturer struct { + modelName string + generate GenerateFunc + store *store.Store + privacy *PrivacyFilter +} + +// New creates a Capturer. pf may be nil (no privacy filtering applied). +func New(modelName string, generate GenerateFunc, st *store.Store, pf *PrivacyFilter) *Capturer { + return &Capturer{ + modelName: modelName, + generate: generate, + store: st, + privacy: pf, + } +} + +// CaptureAll captures all active monitors and returns an image per monitor. +func CaptureAll() ([]image.Image, error) { + n := screenshot.NumActiveDisplays() + if n == 0 { + return nil, fmt.Errorf("capture: no active displays found") + } + images := make([]image.Image, 0, n) + for i := 0; i < n; i++ { + bounds := screenshot.GetDisplayBounds(i) + img, err := screenshot.CaptureRect(bounds) + if err != nil { + return nil, fmt.Errorf("capture: display %d: %w", i, err) + } + images = append(images, img) + } + return images, nil +} + +// OCR extracts text from img using the tesseract CLI. +// Returns an empty string (without error) when tesseract is not installed. +func OCR(img image.Image) (string, error) { + tmp, err := os.CreateTemp("", "super-ollama-capture-*.png") + if err != nil { + return "", fmt.Errorf("ocr: create temp file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if err := png.Encode(tmp, img); err != nil { + tmp.Close() + return "", fmt.Errorf("ocr: encode png: %w", err) + } + tmp.Close() + + out, err := exec.Command("tesseract", tmpName, "stdout", "-l", "eng").Output() + if err != nil { + // If tesseract is not installed, return empty string rather than an error + // so captures degrade gracefully on systems without Tesseract. + if errors.Is(err, exec.ErrNotFound) { + return "", nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("ocr: tesseract: %w (stderr: %s)", err, string(exitErr.Stderr)) + } + return "", fmt.Errorf("ocr: tesseract: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// Summarise calls the LLM to produce a 2-sentence summary of rawText. +// Returns an empty string without error when rawText is empty. +func (c *Capturer) Summarise(ctx context.Context, rawText string) (string, error) { + if strings.TrimSpace(rawText) == "" { + return "", nil + } + prompt := "Summarise the following screen content in 2 sentences:\n\n" + rawText + return c.generate(ctx, c.modelName, prompt) +} + +// IndexCapture saves summary and rawText as a screenshot event in the store. +func (c *Capturer) IndexCapture(summary, rawText string) error { + _, err := c.store.InsertEvent("screenshot", rawText, summary) + return err +} + +// Snap performs a one-shot capture pipeline: +// +// screenshots → OCR → privacy check → LLM summarise → index → return summaries +func (c *Capturer) Snap(ctx context.Context) ([]string, error) { + imgs, err := CaptureAll() + if err != nil { + return nil, err + } + var summaries []string + for i, img := range imgs { + rawText, err := OCR(img) + if err != nil { + return nil, fmt.Errorf("snap: ocr display %d: %w", i, err) + } + if c.privacy != nil && c.privacy.MatchesText(rawText) { + continue + } + summary, err := c.Summarise(ctx, rawText) + if err != nil { + return nil, fmt.Errorf("snap: summarise display %d: %w", i, err) + } + if err := c.IndexCapture(summary, rawText); err != nil { + return nil, fmt.Errorf("snap: index display %d: %w", i, err) + } + if summary != "" { + summaries = append(summaries, summary) + } + } + return summaries, nil +} diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go new file mode 100644 index 00000000000..8d57cd1fc18 --- /dev/null +++ b/internal/capture/capture_test.go @@ -0,0 +1,142 @@ +package capture_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ollama/ollama/internal/capture" +) + +// TestPrivacyFilter_MatchesText verifies keyword matching is case-insensitive. +func TestPrivacyFilter_MatchesText(t *testing.T) { + tests := []struct { + name string + toml string + text string + matched bool + }{ + { + name: "keyword match case-insensitive", + toml: `skip_keywords = ["password"]`, + text: "Enter your PASSWORD here", + matched: true, + }, + { + name: "keyword no match", + toml: `skip_keywords = ["password"]`, + text: "Enter your username here", + matched: false, + }, + { + name: "multiple keywords first match", + toml: `skip_keywords = ["secret", "token"]`, + text: "api_token = abc123", + matched: true, + }, + { + name: "multiple keywords second match", + toml: `skip_keywords = ["password", "BEGIN RSA"]`, + text: "-----BEGIN RSA PRIVATE KEY-----", + matched: true, + }, + { + name: "empty filter allows all", + toml: ``, + text: "password secret token", + matched: false, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + pf := buildFilterFromTOML(t, tc.toml) + got := pf.MatchesText(tc.text) + if got != tc.matched { + t.Errorf("MatchesText(%q) = %v, want %v", tc.text, got, tc.matched) + } + }) + } +} + +// TestPrivacyFilter_MatchesTitle verifies window title pattern matching. +func TestPrivacyFilter_MatchesTitle(t *testing.T) { + tests := []struct { + name string + toml string + title string + matched bool + }{ + { + name: "exact match case-insensitive", + toml: `skip_window_titles = ["1Password"]`, + title: "1password", + matched: true, + }, + { + name: "substring match", + toml: `skip_window_titles = ["Signal"]`, + title: "Signal — encrypted messaging", + matched: true, + }, + { + name: "no match", + toml: `skip_window_titles = ["KeePass"]`, + title: "Visual Studio Code", + matched: false, + }, + { + name: "empty filter allows all", + toml: ``, + title: "1Password", + matched: false, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + pf := buildFilterFromTOML(t, tc.toml) + got := pf.MatchesTitle(tc.title) + if got != tc.matched { + t.Errorf("MatchesTitle(%q) = %v, want %v", tc.title, got, tc.matched) + } + }) + } +} + +// TestLoadPrivacyFilter_MissingFile verifies that a missing privacy.toml +// produces an empty (allow-all) filter without an error. +func TestLoadPrivacyFilter_MissingFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + pf, err := capture.LoadPrivacyFilter() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pf.MatchesText("password secret token") { + t.Error("empty filter should not match any text") + } + if pf.MatchesTitle("1Password") { + t.Error("empty filter should not match any title") + } +} + +// buildFilterFromTOML writes the given TOML to a temp privacy.toml, +// sets XDG_CONFIG_HOME, and returns the loaded PrivacyFilter. +func buildFilterFromTOML(t *testing.T, content string) *capture.PrivacyFilter { + t.Helper() + dir := t.TempDir() + cfgDir := filepath.Join(dir, "super-ollama") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "privacy.toml"), []byte(content), 0o600); err != nil { + t.Fatalf("writeFile: %v", err) + } + t.Setenv("XDG_CONFIG_HOME", dir) + pf, err := capture.LoadPrivacyFilter() + if err != nil { + t.Fatalf("LoadPrivacyFilter: %v", err) + } + return pf +} diff --git a/internal/capture/privacy.go b/internal/capture/privacy.go new file mode 100644 index 00000000000..e0faf281bc5 --- /dev/null +++ b/internal/capture/privacy.go @@ -0,0 +1,96 @@ +package capture + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// privacyConfig mirrors the structure of privacy.toml. +type privacyConfig struct { + SkipWindowTitles []string `toml:"skip_window_titles"` + SkipKeywords []string `toml:"skip_keywords"` +} + +// PrivacyFilter decides whether a capture should be skipped based on window +// titles and OCR-extracted keyword matches. +type PrivacyFilter struct { + titlePatterns []*regexp.Regexp + keywords []string +} + +// LoadPrivacyFilter reads the privacy filter from ~/.super-ollama/privacy.toml +// (or $XDG_CONFIG_HOME/super-ollama/privacy.toml). +// A missing file is not an error; it returns an empty (allow-all) filter. +func LoadPrivacyFilter() (*PrivacyFilter, error) { + path, err := privacyFilterPath() + if err != nil { + return &PrivacyFilter{}, nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &PrivacyFilter{}, nil + } + return nil, err + } + var cfg privacyConfig + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return newPrivacyFilter(cfg), nil +} + +func privacyFilterPath() (string, error) { + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "super-ollama", "privacy.toml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".super-ollama", "privacy.toml"), nil +} + +// newPrivacyFilter builds a PrivacyFilter from a parsed config. +// Window-title entries are compiled as case-insensitive substring matchers. +func newPrivacyFilter(cfg privacyConfig) *PrivacyFilter { + pf := &PrivacyFilter{ + keywords: cfg.SkipKeywords, + } + for _, title := range cfg.SkipWindowTitles { + if title == "" { + continue + } + re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(title)) + if err == nil { + pf.titlePatterns = append(pf.titlePatterns, re) + } + } + return pf +} + +// MatchesText returns true if rawText contains any blocked keyword +// (case-insensitive). +func (pf *PrivacyFilter) MatchesText(rawText string) bool { + lower := strings.ToLower(rawText) + for _, kw := range pf.keywords { + if strings.Contains(lower, strings.ToLower(kw)) { + return true + } + } + return false +} + +// MatchesTitle returns true if title matches any blocked window title pattern. +func (pf *PrivacyFilter) MatchesTitle(title string) bool { + for _, re := range pf.titlePatterns { + if re.MatchString(title) { + return true + } + } + return false +} diff --git a/internal/capture/scheduler.go b/internal/capture/scheduler.go new file mode 100644 index 00000000000..6ca8bec7518 --- /dev/null +++ b/internal/capture/scheduler.go @@ -0,0 +1,36 @@ +package capture + +import ( + "context" + "fmt" + + "github.com/ollama/ollama/internal/ui" + "github.com/robfig/cron/v3" +) + +// StartScheduler registers a periodic capture job using the robfig/cron library. +// intervalMinutes is the delay between captures; values below 1 default to 5. +// The returned stop function drains the scheduler and blocks until the last +// in-flight job completes. +func StartScheduler(ctx context.Context, c *Capturer, intervalMinutes int) (stop func(), err error) { + if intervalMinutes < 1 { + intervalMinutes = 5 + } + cr := cron.New() + spec := fmt.Sprintf("@every %dm", intervalMinutes) + _, err = cr.AddFunc(spec, func() { + summaries, snapErr := c.Snap(ctx) + if snapErr != nil { + ui.Eprintf("capture scheduler: %v\n", snapErr) + return + } + for _, s := range summaries { + ui.Printf("[snap] %s\n", s) + } + }) + if err != nil { + return nil, fmt.Errorf("capture: register cron job: %w", err) + } + cr.Start() + return func() { <-cr.Stop().Done() }, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 1e6dbe87c30..bb902372de9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,7 +12,9 @@ const defaultModel = "gemma3:1b" // Config holds super-ollama runtime settings from config.toml. type Config struct { - DefaultModel string `toml:"default_model"` + DefaultModel string `toml:"default_model"` + CaptureIntervalMinutes int `toml:"capture_interval_minutes"` + CaptureEnabled bool `toml:"capture_enabled"` } // ResolvedPath returns the path to the active config file. @@ -31,12 +33,12 @@ func ResolvedPath() (string, error) { func Load() (Config, error) { path, err := ResolvedPath() if err != nil { - return Config{DefaultModel: defaultModel}, err + return Config{DefaultModel: defaultModel, CaptureIntervalMinutes: 5}, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return Config{DefaultModel: defaultModel}, nil + return Config{DefaultModel: defaultModel, CaptureIntervalMinutes: 5}, nil } return Config{}, err } @@ -47,5 +49,8 @@ func Load() (Config, error) { if strings.TrimSpace(c.DefaultModel) == "" { c.DefaultModel = defaultModel } + if c.CaptureIntervalMinutes < 1 { + c.CaptureIntervalMinutes = 5 + } return c, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000000..0932029088b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ollama/ollama/internal/config" +) + +// TestLoad_Defaults verifies defaults when config.toml is absent. +func TestLoad_Defaults(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DefaultModel == "" { + t.Error("DefaultModel must not be empty") + } + if cfg.CaptureIntervalMinutes < 1 { + t.Errorf("CaptureIntervalMinutes = %d, want >= 1", cfg.CaptureIntervalMinutes) + } +} + +// TestLoad_CaptureFields verifies that capture fields are read from config.toml. +func TestLoad_CaptureFields(t *testing.T) { + dir := t.TempDir() + cfgDir := filepath.Join(dir, "super-ollama") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + content := ` +default_model = "llama3:8b" +capture_interval_minutes = 10 +capture_enabled = true +` + if err := os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", dir) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DefaultModel != "llama3:8b" { + t.Errorf("DefaultModel = %q, want llama3:8b", cfg.DefaultModel) + } + if cfg.CaptureIntervalMinutes != 10 { + t.Errorf("CaptureIntervalMinutes = %d, want 10", cfg.CaptureIntervalMinutes) + } + if !cfg.CaptureEnabled { + t.Error("CaptureEnabled = false, want true") + } +} + +// TestLoad_CaptureIntervalDefault verifies that a zero capture_interval_minutes +// is replaced with the default of 5. +func TestLoad_CaptureIntervalDefault(t *testing.T) { + dir := t.TempDir() + cfgDir := filepath.Join(dir, "super-ollama") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + content := `capture_interval_minutes = 0` + if err := os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", dir) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.CaptureIntervalMinutes != 5 { + t.Errorf("CaptureIntervalMinutes = %d, want 5 (default)", cfg.CaptureIntervalMinutes) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 00000000000..5b7f52fdee4 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,86 @@ +// Package store provides a minimal SQLite-backed data store for super-ollama events. +package store + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Store wraps a SQLite database. +type Store struct { + db *sql.DB +} + +// Event holds a single recorded event. +type Event struct { + ID int64 + TS int64 + Source string + RawText string + Summary string +} + +// Open opens or creates the database at dbPath, running schema migrations. +func Open(dbPath string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { + return nil, fmt.Errorf("store: mkdir: %w", err) + } + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + if err := migrate(db); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +// DefaultPath returns the default database path under ~/.super-ollama/. +func DefaultPath() (string, error) { + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "super-ollama", "data.db"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".super-ollama", "data.db"), nil +} + +// Close closes the database. +func (s *Store) Close() error { + return s.db.Close() +} + +// InsertEvent inserts a new event record and returns its ID. +func (s *Store) InsertEvent(source, rawText, summary string) (int64, error) { + res, err := s.db.Exec( + `INSERT INTO events (ts, source, raw_text, summary) VALUES (?, ?, ?, ?)`, + time.Now().UnixMilli(), source, rawText, summary, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// migrate creates the schema if it does not already exist. +func migrate(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + source TEXT NOT NULL, + raw_text TEXT, + summary TEXT, + tags TEXT, + embedding_id TEXT + )`) + return err +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 00000000000..e3397a78318 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,82 @@ +package store_test + +import ( + "path/filepath" + "testing" + + "github.com/ollama/ollama/internal/store" +) + +// TestStore_InsertEvent verifies that events can be inserted and the returned +// ID is positive. +func TestStore_InsertEvent(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + id, err := st.InsertEvent("screenshot", "raw text content", "a two-sentence summary") + if err != nil { + t.Fatalf("InsertEvent: %v", err) + } + if id <= 0 { + t.Errorf("expected positive ID, got %d", id) + } +} + +// TestStore_InsertEvent_EmptyFields verifies that empty raw_text and summary +// are accepted (both are nullable in the schema). +func TestStore_InsertEvent_EmptyFields(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + id, err := st.InsertEvent("manual", "", "") + if err != nil { + t.Fatalf("InsertEvent with empty fields: %v", err) + } + if id <= 0 { + t.Errorf("expected positive ID, got %d", id) + } +} + +// TestStore_InsertEvent_Timestamp verifies that multiple events can be inserted +// quickly in sequence (timestamps are populated without error). +func TestStore_InsertEvent_Timestamp(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + for i := range 3 { + _, err := st.InsertEvent("screenshot", "text", "summary") + if err != nil { + t.Fatalf("InsertEvent %d: %v", i, err) + } + } +} + +// TestStore_MultipleInserts verifies IDs are monotonically increasing. +func TestStore_MultipleInserts(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + id1, err := st.InsertEvent("screenshot", "text1", "summary1") + if err != nil { + t.Fatal(err) + } + id2, err := st.InsertEvent("screenshot", "text2", "summary2") + if err != nil { + t.Fatal(err) + } + if id2 <= id1 { + t.Errorf("expected id2 (%d) > id1 (%d)", id2, id1) + } +} + +// openTestStore creates a Store backed by a temp-dir SQLite file. +func openTestStore(t *testing.T) *store.Store { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} \ No newline at end of file