diff --git a/cmd/llmem/main.go b/cmd/llmem/main.go index eb5768b..df01003 100644 --- a/cmd/llmem/main.go +++ b/cmd/llmem/main.go @@ -13,7 +13,10 @@ import ( "github.com/MichielDean/LLMem/internal/config" "github.com/MichielDean/LLMem/internal/dream" + "github.com/MichielDean/LLMem/internal/embed" + "github.com/MichielDean/LLMem/internal/extract" "github.com/MichielDean/LLMem/internal/introspect" + "github.com/MichielDean/LLMem/internal/ollama" "github.com/MichielDean/LLMem/internal/paths" "github.com/MichielDean/LLMem/internal/session" "github.com/MichielDean/LLMem/internal/store" @@ -121,6 +124,42 @@ func openAdapter() (session.SessionAdapter, error) { return adapter, nil } +// openExtractionEngine creates an ExtractionEngine for session hooks. +// Returns nil on failure — the coordinator gracefully handles a nil engine +// by skipping extraction (graceful degradation). +func openExtractionEngine() *extract.ExtractionEngine { + engine, err := extract.NewExtractionEngine(extract.ExtractionConfig{}) + if err != nil { + slog.Debug("llmem: failed to create extraction engine, skipping", "error", err) + return nil + } + return engine +} + +// openEmbeddingEngine creates an EmbeddingEngine for session hooks. +// Returns nil on failure — the coordinator gracefully handles a nil engine +// by storing memories without embeddings. +func openEmbeddingEngine() *embed.EmbeddingEngine { + engine, err := embed.NewEmbeddingEngine(embed.EmbeddingConfig{}) + if err != nil { + slog.Debug("llmem: failed to create embedding engine, skipping", "error", err) + return nil + } + return engine +} + +// openOllamaClient creates an OllamaClient for session hook introspection. +// Returns nil on failure — the coordinator gracefully handles a nil client +// by falling back to degraded introspection in OnEnding (plain-text summary, no LLM). +func openOllamaClient() *ollama.OllamaClient { + client, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{}) + if err != nil { + slog.Debug("llmem: failed to create Ollama client, falling back to degraded introspection", "error", err) + return nil + } + return client +} + func addCmd() *cobra.Command { var ( typeVal string @@ -1080,8 +1119,11 @@ func contextCmd() *cobra.Command { } coord, err := session.NewSessionHookCoordinator(session.SessionHookConfig{ - Store: ms, - Adapter: adapter, + Store: ms, + Adapter: adapter, + ExtractionEngine: openExtractionEngine(), + Embedding: openEmbeddingEngine(), + OllamaClient: openOllamaClient(), }) if err != nil { return err @@ -1146,8 +1188,11 @@ func hookCmd() *cobra.Command { } coord, err := session.NewSessionHookCoordinator(session.SessionHookConfig{ - Store: ms, - Adapter: adapter, + Store: ms, + Adapter: adapter, + ExtractionEngine: openExtractionEngine(), + Embedding: openEmbeddingEngine(), + OllamaClient: openOllamaClient(), }) if err != nil { return err diff --git a/docs/API.md b/docs/API.md index bc408da..a20425f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -906,7 +906,7 @@ _, err := ms.Add(ctx, store.AddParams{Type: "unknown_type", Content: "test"}) Embeddings are stored and accepted as packed `[]byte` in little-endian `float32` format. For a 768-dimensional embedding, this is `768 × 4 = 3072` bytes. -Use the exported `vecToBytes` and `bytesToVec` helpers if you need conversion: +Use the exported `VecToBytes` and `BytesToVec` helpers if you need conversion: ```go // Convert float32 slice to []byte for storage @@ -1251,7 +1251,7 @@ available := engine.CheckAvailable(ctx) ### Introspection (internal/introspect) -The `internal/introspect` package provides failure analysis and lesson learning (see [Dream Cycle & Extraction](DREAM.md#go) for usage). +The `internal/introspect` package provides failure analysis, lesson learning, and session transcript introspection (see [Dream Cycle & Extraction](DREAM.md#go) for usage). ```go import "github.com/MichielDean/LLMem/internal/introspect" @@ -1269,9 +1269,17 @@ id, err := introspect.LearnLesson(ctx, ms, introspect.LearnLessonParams{ WhatIsCorrect: "inject dependency via constructor", Context: "service.go:15", }) + +// IntrospectTranscript — analyze a session transcript at session end +id, err := introspect.IntrospectTranscript(ctx, ms, transcript, "session-id", ollamaClient, "glm-5.1:cloud") +// When ollamaClient is nil, falls back to degraded storage (plain-text summary, no LLM call) ``` -Both functions use LLM expansion via Ollama when available. When Ollama is unavailable, they gracefully degrade to storage-only mode (storing the raw parameters without LLM expansion). +All three functions use LLM expansion via Ollama when available. When Ollama is unavailable, they gracefully degrade to storage-only mode (storing the raw parameters without LLM expansion). + +**IntrospectTranscript** differs from `IntrospectFailure` and `LearnLesson` in two ways: +1. It accepts a pre-configured `*ollama.OllamaClient` instead of a model/baseURL pair, reusing the session's configured Ollama connection. +2. It uses `context.Background()` for the final store operation (not the caller's `ctx`), ensuring the session-end self-assessment is persisted even if the calling context has expired during the LLM call. This is intentional — `IntrospectFailure` and `LearnLesson` pass through `ctx` because they run mid-session when the context is still alive. #### IntrospectAuto @@ -1433,23 +1441,35 @@ if adapter != nil { } coord, err := session.NewSessionHookCoordinator(session.SessionHookConfig{ - Store: ms, - Adapter: adapter, // nil → no_transcript on idle/ending + Store: ms, + Adapter: adapter, // nil → no_transcript on idle/ending + ExtractionEngine: extractionEngine, // nil → skip extraction + Embedding: embeddingEngine, // nil → store without embeddings + OllamaClient: ollamaClient, // nil → degraded introspection in OnEnding }) ``` When `config.yaml` has `opencode.db_path` set and the database exists, the adapter is wired into the coordinator. When the path is empty or the DB is unreachable, a nil adapter is used — `OnIdle` and `OnEnding` return `"no_transcript"` gracefully. +The CLI also provides `openExtractionEngine()`, `openEmbeddingEngine()`, and `openOllamaClient()` helper functions that return nil on failure. The coordinator gracefully degrades when any of these are nil: +- `ExtractionEngine` nil → extraction skipped, memories not extracted from transcript +- `Embedding` nil → memories stored without embedding vectors +- `OllamaClient` nil → `IntrospectTranscript` produces degraded self-assessment (plain-text summary, no LLM call) + #### SessionHookConfig ```go type SessionHookConfig struct { - Store *store.MemoryStore // Required for all hook operations - Adapter SessionAdapter // Provides session content. nil → no_transcript - DebounceSeconds int // Min interval between idle events. Default: 30 - ContextDir string // Directory for context files. Default: paths.GetContextDir() - Model string // LLM model for introspection. Default: "glm-5.1:cloud" - BaseURL string // Ollama base URL for introspection. Default: "http://localhost:11434" + Store *store.MemoryStore // Required for all hook operations + Adapter SessionAdapter // Provides session content. nil → no_transcript + DebounceSeconds int // Min interval between idle events. Default: 30 + ContextDir string // Directory for context files. Default: paths.GetContextDir() + Model string // LLM model for introspection. Default: "glm-5.1:cloud" + BaseURL string // Ollama base URL for introspection. Default: "http://localhost:11434" + ExtractionEngine *extract.ExtractionEngine // Extracts memories from transcript. nil → skip extraction + Embedding *embed.EmbeddingEngine // Generates embedding vectors. nil → store without embeddings + OllamaClient *ollama.OllamaClient // Used for introspection in OnEnding. nil → degraded fallback + IntrospectModel string // LLM model name for IntrospectTranscript. Default: "glm-5.1:cloud" } ``` @@ -1457,8 +1477,12 @@ type SessionHookConfig struct { ```go coord, err := session.NewSessionHookCoordinator(session.SessionHookConfig{ - Store: ms, - Adapter: adapter, + Store: ms, + Adapter: adapter, + ExtractionEngine: extractionEngine, // nil → skip extraction + Embedding: embeddingEngine, // nil → store without embeddings + OllamaClient: ollamaClient, // nil → degraded introspection in OnEnding + IntrospectModel: "glm-5.1:cloud", // optional, defaults to "glm-5.1:cloud" }) result, err := coord.OnCreated(ctx, "session-id") // "success" | "already_processed" @@ -1474,7 +1498,15 @@ resultType, memoryID, err := coord.OnEndingWithIntrospect(ctx, "session-id") // ("error", "", err) on validation error ``` -All methods validate session IDs via `paths.ValidateSessionID` to prevent path traversal. OnIdle includes a 30-second debounce mechanism. +All methods validate session IDs via `paths.ValidateSessionID` to prevent path traversal. + +**OnIdle** includes a 30-second debounce mechanism. When a transcript is available and `ExtractionEngine` is non-nil, OnIdle: +1. Calls `SupersedeBySource` to invalidate prior memories from the same session (re-extraction as conversation grows) +2. Extracts memories via the extraction engine +3. Generates embedding vectors for each memory (if `Embedding` is non-nil) +4. Stores memories and logs the extraction + +**OnEnding** extracts memories the same way as OnIdle, then runs `IntrospectTranscript` to produce a session-end self-assessment. When `OllamaClient` is nil, `IntrospectTranscript` falls back to a degraded plain-text summary (no LLM call attempted) — the nil-OllamaClient guard must NOT be used, or the degradation path is bypassed. ### Systemd Unit Generation (internal/systemd) diff --git a/docs/CLI.md b/docs/CLI.md index 77c04cd..0f6cab7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -229,7 +229,9 @@ Handle session lifecycle hook events. Supports four hook types: - `--model`: LLM model for introspection (default: `glm-5.1:cloud`). Used by the `ending` hook for automatic introspection. - `--base-url`: Ollama base URL for introspection (default: `http://localhost:11434`). Used by the `ending` hook for automatic introspection. -The `idle` hook processes the session's transcript, extracts memories, and runs introspection automatically. It uses a debounce mechanism (via `extraction_log` table) to prevent re-extraction. +The `idle` hook processes the session's transcript, extracts memories via the extraction pipeline (chunk → dedup → LLM extract → embed → store), and generates embedding vectors for each extracted memory. It uses a debounce mechanism (via `extraction_log` table) to prevent re-extraction. When `ExtractionEngine` is not configured, extraction is skipped gracefully. + +The `ending` hook extracts memories from the transcript (same pipeline as `idle`), then runs `IntrospectTranscript` to produce a session-end `self_assessment` memory. When the LLM is unavailable, `IntrospectTranscript` falls back to a degraded plain-text summary of the session (no LLM call attempted). The `ending` hook performs automatic introspection on the session transcript. It reads the transcript via the configured adapter, generates a `self_assessment` memory using `IntrospectAuto`, and outputs the result type and memory ID. If no adapter is configured or the transcript is empty, it returns `no_transcript`. If introspection fails but the transcript was read, it logs a warning and returns success without crashing the ending event. diff --git a/docs/DREAM.md b/docs/DREAM.md index fa3676f..3a14ada 100644 --- a/docs/DREAM.md +++ b/docs/DREAM.md @@ -189,7 +189,7 @@ result, err := coord.OnEnding(ctx, "session-id") resultType, memoryID, err := coord.OnEndingWithIntrospect(ctx, "session-id") ``` -The `internal/introspect` package provides failure analysis and lesson learning: +The `internal/introspect` package provides failure analysis, lesson learning, and session transcript introspection: ```go import "github.com/MichielDean/LLMem/internal/introspect" @@ -216,4 +216,14 @@ id, err := introspect.LearnLesson(ctx, ms, introspect.LearnLessonParams{ id, err := introspect.IntrospectAuto(ctx, ms, "Session transcript text...", "glm-5.1:cloud", "http://localhost:11434") ``` -All three functions use LLM expansion via Ollama when available, with graceful degradation to storage-only mode when Ollama is unavailable. `IntrospectAuto` never returns `("", nil)` — either creates a memory or returns an error. \ No newline at end of file +All three functions use LLM expansion via Ollama when available, with graceful degradation to storage-only mode when Ollama is unavailable. `IntrospectAuto` never returns `("", nil)` — either creates a memory or returns an error. + +```go +// Introspect a session transcript (called by OnEnding) +id, err := introspect.IntrospectTranscript(ctx, ms, transcript, "session-id", ollamaClient, "glm-5.1:cloud") +// When ollamaClient is nil, falls back to degraded storage (plain-text summary, no LLM call) +``` + +Both `IntrospectFailure` and `LearnLesson` use LLM expansion via Ollama when available, with graceful degradation to storage-only mode when Ollama is unavailable. + +`IntrospectTranscript` analyzes a session transcript and stores a `self_assessment` memory. It accepts a pre-configured `*ollama.OllamaClient` (reusing the session's connection). When `ollamaClient` is nil, it produces a degraded memory with a plain-text summary. On LLM availability, the model generates a structured self-assessment from the transcript content. Note: `IntrospectTranscript` uses `context.Background()` for the final store operation (not the caller's `ctx`) to ensure persistence even if the calling context has expired during the LLM call. diff --git a/internal/embed/embed.go b/internal/embed/embed.go index a237f6c..30b41d7 100644 --- a/internal/embed/embed.go +++ b/internal/embed/embed.go @@ -6,11 +6,9 @@ package embed import ( "bytes" "context" - "encoding/binary" "encoding/json" "fmt" "log/slog" - "math" "net/http" "strings" "sync" @@ -261,28 +259,4 @@ func (e *EmbeddingEngine) CheckAvailable(ctx context.Context) bool { func (e *EmbeddingEngine) Close() error { e.httpClient.CloseIdleConnections() return nil -} - -// vecToBytes encodes a []float32 into packed little-endian bytes. -// Matches Python's struct.pack(f"{dim}f", *vec). -func vecToBytes(vec []float32) []byte { - buf := make([]byte, len(vec)*4) - for i, v := range vec { - binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(v)) - } - return buf -} - -// bytesToVec decodes a packed float32 byte slice into a []float32. -// Matches Python's struct.unpack(f"{dim}f", data). -func bytesToVec(data []byte) []float32 { - if len(data) == 0 { - return nil - } - dim := len(data) / 4 - result := make([]float32, dim) - for i := 0; i < dim; i++ { - result[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[i*4:])) - } - return result } \ No newline at end of file diff --git a/internal/embed/embed_test.go b/internal/embed/embed_test.go index c497fbf..626cc6a 100644 --- a/internal/embed/embed_test.go +++ b/internal/embed/embed_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/MichielDean/LLMem/internal/store" ) func TestEmbeddingEngine_Embed_Success(t *testing.T) { @@ -316,8 +318,8 @@ func TestEmbeddingEngine_CheckAvailable_Unreachable(t *testing.T) { func TestEmbeddingEngine_VecToBytes_RoundTrip(t *testing.T) { original := []float32{1.0, -2.5, 3.14, 0.0, -0.001} - encoded := vecToBytes(original) - decoded := bytesToVec(encoded) + encoded := store.VecToBytes(original) + decoded := store.BytesToVec(encoded) if len(decoded) != len(original) { t.Fatalf("expected %d elements, got %d", len(original), len(decoded)) diff --git a/internal/introspect/introspect.go b/internal/introspect/introspect.go index 10ee1ec..12a05c8 100644 --- a/internal/introspect/introspect.go +++ b/internal/introspect/introspect.go @@ -405,6 +405,115 @@ func callModel(ctx context.Context, model, baseURL, prompt string, timeout time. return response, Enriched } +// callModelWithClient calls the Ollama model using a pre-configured OllamaClient. +// Returns empty string on failure (never panics). +// If ollamaClient is nil, returns "" immediately (graceful no-op, no network call). +// Uses a bounded timeout so callers never block indefinitely. +func callModelWithClient(ctx context.Context, ollamaClient *ollama.OllamaClient, model, prompt string) string { + if ollamaClient == nil { + slog.Debug("llmem: introspect: no OllamaClient provided, using storage-only fallback") + return "" + } + + timeoutCtx, cancel := context.WithTimeout(ctx, callModelTimeout) + defer cancel() + + if !ollamaClient.IsAvailable(timeoutCtx) { + slog.Debug("llmem: introspect: Ollama not available, using storage-only fallback") + return "" + } + + response, err := ollamaClient.Generate(timeoutCtx, prompt, model) + if err != nil { + slog.Error("llmem: introspect: model call failed", "error", err) + return "" + } + + return response +} + +// IntrospectTranscript analyzes a session transcript and stores a self_assessment memory. +// On LLM availability, it uses the model to produce a structured self-assessment from the +// transcript content. On LLM failure, it falls back to a plain-text summary of the session. +// +// The ollamaClient parameter provides the configured Ollama connection. If nil, +// IntrospectTranscript falls back to degraded storage immediately (no LLM call attempted). +// +// Contract: NEVER returns ("", nil) — either creates a memory or returns an error. +// Even on LLM failure, a degraded memory is created. +// Returns ("", error) only if the transcript is empty (validation error). +func IntrospectTranscript(ctx context.Context, ms *store.MemoryStore, transcript string, sessionID string, ollamaClient *ollama.OllamaClient, model string) (string, error) { + if transcript == "" { + return "", fmtErr("transcript is required for introspection") + } + if model == "" { + model = defaultModel + } + + var content string + prompt := buildTranscriptPrompt(transcript, sessionID) + llmResponse := callModelWithClient(ctx, ollamaClient, model, prompt) + + if llmResponse != "" { + content = llmResponse + } else { + // Graceful degradation: build from provided fields + var lines []string + lines = append(lines, "Session_id: "+sessionID) + lines = append(lines, "Summary: session completed") + // Truncate transcript for storage if very long + transcriptExcerpt := transcript + if len(transcriptExcerpt) > 500 { + transcriptExcerpt = transcriptExcerpt[:500] + "..." + } + lines = append(lines, "Transcript_excerpt: "+transcriptExcerpt) + content = strings.Join(lines, "\n") + } + + // Use context.Background() for the store operation to avoid data loss when + // the caller's context has expired (e.g., session-ending timeouts). The LLM + // call above respects ctx, but the final store must succeed regardless of + // context cancellation. This is intentional: IntrospectTranscript is called + // at session end and must persist its finding even if the original context + // has timed out. This differs from IntrospectFailure and LearnLesson which + // pass through ctx because they are called mid-session when the context is + // still alive. + id, err := ms.Add(context.Background(), store.AddParams{ + Type: "self_assessment", + Content: content, + Source: introspectSource, + Confidence: 0.8, + }) + if err != nil { + return "", fmtErr("store self_assessment: %w", err) + } + + slog.Info("llmem: introspect: stored transcript self_assessment", "id", id, "session_id", sessionID) + return id, nil +} + +// buildTranscriptPrompt builds the prompt for transcript introspection. +func buildTranscriptPrompt(transcript, sessionID string) string { + prompt := "Analyze the following session transcript and produce a structured self-assessment.\n\n" + prompt += "The session has ended. Identify key decisions, preferences, project state updates, " + + "and any lessons learned during this session.\n\n" + prompt += "Format each insight as a separate point:\n" + + "- What was decided and why\n" + + "- What preferences were expressed\n" + + "- What project state changed\n" + + "- What patterns or recurring issues appeared\n\n" + prompt += "Session ID: " + sessionID + "\n\n" + + // Truncate very long transcripts to avoid overwhelming the model + transcriptExcerpt := transcript + if len(transcriptExcerpt) > 3000 { + transcriptExcerpt = transcriptExcerpt[:3000] + "\n...(truncated)" + } + prompt += "Transcript:\n" + transcriptExcerpt + prompt += "\n\nProduce a structured self-assessment of this session." + return prompt +} + // orDefault returns val if non-empty, otherwise returns defaultVal. func orDefault(val, defaultVal string) string { if val != "" { diff --git a/internal/introspect/introspect_test.go b/internal/introspect/introspect_test.go index a8f8574..f71637f 100644 --- a/internal/introspect/introspect_test.go +++ b/internal/introspect/introspect_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/MichielDean/LLMem/internal/ollama" "github.com/MichielDean/LLMem/internal/store" ) @@ -707,4 +708,152 @@ func TestIntrospectAuto_WithModel(t *testing.T) { if mem.Source != "introspect-auto" { t.Errorf("expected source introspect-auto, got %q", mem.Source) } +} + +func TestIntrospectTranscript_WithFields(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ms := newTestStore(t) + // Nil OllamaClient triggers graceful degradation (no LLM call) + id, err := IntrospectTranscript(ctx, ms, "User asked about Go testing conventions\nAssistant explained table-driven tests", "session-abc", nil, "") + if err != nil { + t.Fatalf("IntrospectTranscript: %v", err) + } + if id == "" { + t.Error("expected non-empty memory ID") + } + + mem, err := ms.Get(context.Background(), id, false) + if err != nil { + t.Fatalf("Get: %v", err) + } + if mem == nil { + t.Fatal("expected memory to be stored") + } + if mem.Type != "self_assessment" { + t.Errorf("expected type self_assessment, got %q", mem.Type) + } + if mem.Source != "introspect" { + t.Errorf("expected source introspect, got %q", mem.Source) + } + if mem.Confidence != 0.8 { + t.Errorf("expected confidence 0.8, got %f", mem.Confidence) + } + if mem.Content == "" { + t.Error("expected non-empty content") + } +} + +func TestIntrospectTranscript_EmptyTranscript(t *testing.T) { + ctx := context.Background() + ms := newTestStore(t) + _, err := IntrospectTranscript(ctx, ms, "", "session-abc", nil, "") + if err == nil { + t.Error("expected error for empty transcript") + } +} + +func TestIntrospectTranscript_GracefulDegradation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ms := newTestStore(t) + // Nil OllamaClient triggers graceful degradation + id, err := IntrospectTranscript(ctx, ms, "Some session transcript content here", "session-degrad", nil, "") + if err != nil { + t.Fatalf("IntrospectTranscript: %v", err) + } + if id == "" { + t.Error("expected non-empty memory ID even when OllamaClient is nil") + } + + mem, _ := ms.Get(context.Background(), id, false) + if mem == nil { + t.Fatal("expected memory to be stored") + } + if mem.Content == "" { + t.Error("expected non-empty content even without LLM") + } + if !strings.Contains(mem.Content, "session-degrad") { + t.Errorf("expected degraded content to contain session ID, got %q", mem.Content) + } +} + +func TestIntrospectTranscript_WithOllamaClient(t *testing.T) { + // Create a mock Ollama server that returns a structured self-assessment + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + resp := map[string]any{"models": []map[string]string{{"name": "test-model"}}} + json.NewEncoder(w).Encode(resp) + return + } + if r.URL.Path == "/api/generate" { + resp := map[string]string{ + "response": "Category: PROJECT_STATE\nWhat_happened: Session reviewed testing patterns\nProposed_update: adopt table-driven tests", + } + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ms := newTestStore(t) + id, err := IntrospectTranscript(ctx, ms, "We discussed testing patterns and decided on table-driven tests", "session-client", client, "test-model") + if err != nil { + t.Fatalf("IntrospectTranscript: %v", err) + } + if id == "" { + t.Error("expected non-empty memory ID") + } + + mem, err := ms.Get(context.Background(), id, false) + if err != nil { + t.Fatalf("Get: %v", err) + } + if mem == nil { + t.Fatal("expected memory to be stored") + } + if mem.Type != "self_assessment" { + t.Errorf("expected type self_assessment, got %q", mem.Type) + } + if mem.Content == "" { + t.Error("expected non-empty content from LLM response") + } +} + +func TestIntrospectTranscript_NilOllamaClient_DegradedContent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ms := newTestStore(t) + // nil OllamaClient means no LLM call — graceful degradation to plain content + id, err := IntrospectTranscript(ctx, ms, "Brief session notes", "session-nil-client", nil, "") + if err != nil { + t.Fatalf("IntrospectTranscript: %v", err) + } + if id == "" { + t.Error("expected non-empty memory ID") + } + + mem, _ := ms.Get(context.Background(), id, false) + if mem == nil { + t.Fatal("expected memory to be stored") + } + // Degraded content should include the session ID + if !strings.Contains(mem.Content, "session-nil-client") { + t.Errorf("expected degraded content to include session ID, got %q", mem.Content) + } } \ No newline at end of file diff --git a/internal/session/session.go b/internal/session/session.go index 5049171..e0b3c21 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -16,7 +16,10 @@ import ( _ "modernc.org/sqlite" + "github.com/MichielDean/LLMem/internal/embed" + "github.com/MichielDean/LLMem/internal/extract" "github.com/MichielDean/LLMem/internal/introspect" + "github.com/MichielDean/LLMem/internal/ollama" "github.com/MichielDean/LLMem/internal/paths" "github.com/MichielDean/LLMem/internal/store" ) @@ -389,6 +392,21 @@ type SessionHookConfig struct { // BaseURL is the Ollama base URL for introspection. Defaults to "http://localhost:11434" if zero. BaseURL string + + // ExtractionEngine extracts memories from text. If nil, idle and ending skip extraction. + ExtractionEngine *extract.ExtractionEngine + + // Embedding generates embedding vectors. If nil, memories are stored without embeddings. + Embedding *embed.EmbeddingEngine + + // OllamaClient is used for introspection in OnEnding. If nil, IntrospectTranscript + // falls back to degraded storage immediately (no LLM call attempted) and still + // produces a self_assessment memory with a plain-text session summary. + OllamaClient *ollama.OllamaClient + + // IntrospectModel is the LLM model name for IntrospectTranscript. + // Defaults to "glm-5.1:cloud" if empty. + IntrospectModel string } // SessionHookCoordinator orchestrates memory operations for session lifecycle events. @@ -397,6 +415,10 @@ type SessionHookCoordinator struct { adapter SessionAdapter contextDir string debounceSeconds int + extractor *extract.ExtractionEngine + embedder *embed.EmbeddingEngine + ollamaClient *ollama.OllamaClient + introspectModel string lastIdle map[string]time.Time mu sync.Mutex model string @@ -430,6 +452,10 @@ func NewSessionHookCoordinator(cfg SessionHookConfig) (*SessionHookCoordinator, adapter: cfg.Adapter, contextDir: contextDir, debounceSeconds: debounceSeconds, + extractor: cfg.ExtractionEngine, + embedder: cfg.Embedding, + ollamaClient: cfg.OllamaClient, + introspectModel: cfg.IntrospectModel, lastIdle: map[string]time.Time{}, model: cfg.Model, baseURL: cfg.BaseURL, @@ -496,7 +522,12 @@ func (c *SessionHookCoordinator) OnIdle(ctx context.Context, sessionID string) ( return ResultNoTranscript, nil } - _ = transcript // transcript content would be used for extraction + // Extract memories from transcript if extraction engine is available + if c.extractor != nil { + extracted := c.extractMemories(ctx, transcript, validID) + _ = extracted // extracted count logged by extractMemories + } + logSessionEvent("idle", validID) return ResultSuccess, nil } @@ -566,7 +597,38 @@ func (c *SessionHookCoordinator) OnEnding(ctx context.Context, sessionID string) return ResultNoTranscript, nil } - _ = transcript // transcript content would be used for extraction + // Extract memories from transcript if extraction engine is available + if c.extractor != nil { + extracted := c.extractMemories(ctx, transcript, validID) + _ = extracted // extracted count logged by extractMemories + } + + // Run introspection on the full session transcript. + // IntrospectTranscript handles nil OllamaClient gracefully by producing + // degraded self_assessment content (no LLM call attempted). Do NOT guard + // with c.ollamaClient != nil — that bypasses the function's own nil-client + // degradation and results in zero introspection memories for sessions with + // valid transcripts but no configured Ollama connection. + introspectID, err := introspect.IntrospectTranscript(ctx, c.store, transcript, validID, c.ollamaClient, c.introspectModel) + if err != nil { + slog.Warn("llmem: session: on_ending: introspect_transcript failed, storing degraded event", "error", err, "session_id", validID) + // Fall back to storing a simple session-end event. + // Use context.Background() for the fallback store operation since the + // calling context may have expired during the LLM call. + _, storeErr := c.store.Add(context.Background(), store.AddParams{ + Type: "event", + Content: fmt.Sprintf("Session ended: %s", validID), + Source: "session_end", + Confidence: 0.7, + Metadata: map[string]any{"source_type": "session", "source_id": validID}, + }) + if storeErr != nil { + slog.Debug("llmem: session: on_ending: store session_end event failed", "error", storeErr, "session_id", validID) + } + } else { + slog.Debug("llmem: session: on_ending: stored introspect_transcript memory", "id", introspectID, "session_id", validID) + } + logSessionEvent("ending", validID) return ResultSuccess, nil } @@ -609,6 +671,85 @@ func (c *SessionHookCoordinator) OnEndingWithIntrospect(ctx context.Context, ses return ResultSuccess, memoryID, nil } +// extractMemories extracts memories from a transcript, handles dedup via SupersedeBySource, +// stores each extracted memory, embeds if possible, and logs the extraction. +// Returns the number of memories successfully stored. +// Errors are logged and gracefully degraded — this method never returns an error. +func (c *SessionHookCoordinator) extractMemories(ctx context.Context, transcript, validID string) int { + // Supersede prior memories from this session before extracting fresh ones. + // OnIdle intentionally re-extracts as the conversation grows, so we + // invalidate old memories rather than checking IsExtracted. + superceded, err := c.store.SupersedeBySource(ctx, "session", validID) + if err != nil { + slog.Debug("llmem: session: supersede_by_source failed", "error", err, "session_id", validID) + } else if superceded > 0 { + slog.Debug("llmem: session: superseded prior session memories", "count", superceded, "session_id", validID) + } + + extractedMaps := c.extractor.Extract(ctx, transcript) + if len(extractedMaps) == 0 { + slog.Debug("llmem: session: extraction returned no memories", "session_id", validID) + // Log the extraction event even if no memories were extracted + if logErr := c.store.LogExtraction(ctx, "session", validID, &transcript, 0); logErr != nil { + slog.Debug("llmem: session: log_extraction failed", "error", logErr, "session_id", validID) + } + return 0 + } + + storedCount := 0 + for _, m := range extractedMaps { + memType, _ := m["type"].(string) + content, _ := m["content"].(string) + confidence, _ := m["confidence"].(float64) + + // Default values per the spec + if memType == "" { + memType = "fact" + } + if content == "" { + continue // skip entries without content + } + if confidence == 0 { + confidence = 0.8 + } + + addParams := store.AddParams{ + Type: memType, + Content: content, + Source: "session", + Confidence: confidence, + Metadata: map[string]any{"source_type": "session", "source_id": validID}, + } + + // Generate embedding if embedder is available + if c.embedder != nil { + embedding, embedErr := c.embedder.Embed(ctx, content) + if embedErr != nil { + slog.Debug("llmem: session: embedding failed, storing without embedding", "error", embedErr, "session_id", validID) + } else if len(embedding) > 0 { + addParams.Embedding = store.VecToBytes(embedding) + } + } + + id, addErr := c.store.Add(ctx, addParams) + if addErr != nil { + slog.Debug("llmem: session: store extracted memory failed", "error", addErr, "session_id", validID) + continue + } + storedCount++ + + _ = id // ID is not needed further, but the store returned it + } + + // Log the extraction event + if logErr := c.store.LogExtraction(ctx, "session", validID, &transcript, storedCount); logErr != nil { + slog.Debug("llmem: session: log_extraction failed", "error", logErr, "session_id", validID) + } + + slog.Debug("llmem: session: extracted and stored memories", "count", storedCount, "session_id", validID) + return storedCount +} + // logSessionEvent logs a session event at debug level. func logSessionEvent(eventType, sessionID string) { slog.Debug("llmem: session: event", "type", eventType, "session_id", sessionID) diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 3452d48..d0675cb 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -3,12 +3,18 @@ package session import ( "context" "database/sql" + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" "time" + "github.com/MichielDean/LLMem/internal/embed" + "github.com/MichielDean/LLMem/internal/extract" + "github.com/MichielDean/LLMem/internal/ollama" "github.com/MichielDean/LLMem/internal/store" ) @@ -825,6 +831,7 @@ func TestSessionHookCoordinator_OnEnding_WithAdapter(t *testing.T) { if err != nil { t.Fatalf("OnEnding: %v", err) } + // Without OllamaClient, extraction may run but IntrospectTranscript degrades gracefully if result != ResultSuccess { t.Errorf("expected %q, got %q", ResultSuccess, result) } @@ -939,6 +946,931 @@ func TestOpenCodeAdapter_EmptyDBPath_IsNilInterface(t *testing.T) { } } +// newTestExtractionEngine creates an ExtractionEngine backed by a test HTTP server +// that returns the given JSON array response from the /api/generate endpoint. +func newTestExtractionEngine(t *testing.T, responseMemories []map[string]any) (*extract.ExtractionEngine, *httptest.Server) { + t.Helper() + responseJSON, err := json.Marshal(responseMemories) + if err != nil { + t.Fatalf("marshal test response: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/generate" { + resp := map[string]string{"response": string(responseJSON)} + json.NewEncoder(w).Encode(resp) + return + } + if r.URL.Path == "/api/tags" { + resp := map[string]any{"models": []map[string]string{{"name": "glm-5.1:cloud"}}} + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + + client, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + engine, err := extract.NewExtractionEngine(extract.ExtractionConfig{ + OllamaClient: client, + }) + if err != nil { + t.Fatalf("NewExtractionEngine: %v", err) + } + return engine, server +} + +// newFailingExtractionEngine creates an ExtractionEngine backed by a server +// that always returns errors (simulating unavailable LLM). +func newFailingExtractionEngine(t *testing.T) *extract.ExtractionEngine { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + client, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + engine, err := extract.NewExtractionEngine(extract.ExtractionConfig{ + OllamaClient: client, + }) + if err != nil { + t.Fatalf("NewExtractionEngine: %v", err) + } + return engine +} + +// newTestEmbeddingEngine creates an EmbeddingEngine backed by a test HTTP server. +func newTestEmbeddingEngine(t *testing.T) (*embed.EmbeddingEngine, *httptest.Server) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/embeddings" { + // Return a 4-dimensional embedding vector for testing + resp := map[string]any{ + "embedding": []float32{0.1, 0.2, 0.3, 0.4}, + } + json.NewEncoder(w).Encode(resp) + return + } + if r.URL.Path == "/api/tags" { + resp := map[string]any{"models": []map[string]string{{"name": "nomic-embed-text"}}} + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + + engine, err := embed.NewEmbeddingEngine(embed.EmbeddingConfig{ + HTTPClient: server.Client(), + BaseURL: server.URL, + Dimensions: 4, // small for testing + MaxCacheSize: 100, + }) + if err != nil { + t.Fatalf("NewEmbeddingEngine: %v", err) + } + return engine, server +} + +// newIntrospectTestServer creates an httptest.Server that serves the Ollama +// /api/tags and /api/generate endpoints for introspection tests. +// It returns a structured self-assessment response from /api/generate. +func newIntrospectTestServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + resp := map[string]any{"models": []map[string]string{{"name": "test-model"}}} + json.NewEncoder(w).Encode(resp) + return + } + if r.URL.Path == "/api/generate" { + resp := map[string]string{ + "response": "Category: PROJECT_STATE\nWhat_happened: Session completed\nProposed_update: reviewed patterns", + } + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + return server +} + +func TestSessionHookCoordinator_OnIdle_ExtractsMemories(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-extract", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-extract", "sess-extract", 1000, "user") + insertTestPart(t, dbPath, "part-extract", "msg-extract", "sess-extract", 1000, + `{"type": "text", "text": "The project uses Go 1.22"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "The project uses Go 1.22", "confidence": 0.9}, + }) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-extract") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify memory was stored + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(memories) == 0 { + t.Error("expected at least one memory to be stored") + } + + // Verify the memory content + found := false + for _, m := range memories { + if m.Content == "The project uses Go 1.22" { + found = true + if m.Source != "session" { + t.Errorf("expected source 'session', got %q", m.Source) + } + if m.Confidence != 0.9 { + t.Errorf("expected confidence 0.9, got %f", m.Confidence) + } + if m.Metadata["source_type"] != "session" { + t.Errorf("expected metadata source_type 'session', got %v", m.Metadata["source_type"]) + } + break + } + } + if !found { + t.Error("expected to find memory with content 'The project uses Go 1.22'") + } +} + +func TestSessionHookCoordinator_OnIdle_ExtractsMemories_UnavailableLLM(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-nollm", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-nollm", "sess-nollm", 1000, "user") + insertTestPart(t, dbPath, "part-nollm", "msg-nollm", "sess-nollm", 1000, + `{"type": "text", "text": "Some content for extraction"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor := newFailingExtractionEngine(t) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-nollm") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + // Should return success even when LLM is unavailable — graceful degradation + if result != ResultSuccess { + t.Errorf("expected %q (graceful degradation), got %q", ResultSuccess, result) + } +} + +func TestSessionHookCoordinator_OnIdle_DedupExtraction(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-dedup", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-dedup", "sess-dedup", 1000, "user") + insertTestPart(t, dbPath, "part-dedup", "msg-dedup", "sess-dedup", 1000, + `{"type": "text", "text": "Go 1.22 is the runtime version"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "Go 1.22 is the runtime version", "confidence": 0.85}, + }) + + ms := newTestStore(t) + + // First, mark this session as already extracted (IsExtracted returns true) + err = ms.LogExtraction(context.Background(), "session", "sess-dedup", nil, 1) + if err != nil { + t.Fatalf("LogExtraction: %v", err) + } + + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + // Use a very short debounce to allow re-extraction within the test + coord.debounceSeconds = 0 + + result, err := coord.OnIdle(context.Background(), "sess-dedup") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + // OnIdle should still succeed — it uses SupersedeBySource, not IsExtracted check + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } +} + +func TestSessionHookCoordinator_OnIdle_LogsExtraction(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-log", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-log", "sess-log", 1000, "user") + insertTestPart(t, dbPath, "part-log", "msg-log", "sess-log", 1000, + `{"type": "text", "text": "Important fact to extract"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "Important fact to extract", "confidence": 0.7}, + }) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-log") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify extraction was logged + extracted, err := ms.IsExtracted(context.Background(), "session", "sess-log") + if err != nil { + t.Fatalf("IsExtracted: %v", err) + } + if !extracted { + t.Error("expected extraction to be logged via IsExtracted") + } +} + +func TestSessionHookCoordinator_OnIdle_OverridesPriorMemories(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-supersede", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-supersede", "sess-supersede", 1000, "user") + insertTestPart(t, dbPath, "part-supersede", "msg-supersede", "sess-supersede", 1000, + `{"type": "text", "text": "Updated fact about project"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "Updated fact about project", "confidence": 0.9}, + }) + + ms := newTestStore(t) + + // Pre-add a memory that should be superseded + _, err = ms.Add(context.Background(), store.AddParams{ + Type: "fact", + Content: "Old fact about project", + Source: "session", + Confidence: 0.7, + Metadata: map[string]any{"source_type": "session", "source_id": "sess-supersede"}, + }) + if err != nil { + t.Fatalf("Pre-add memory: %v", err) + } + + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-supersede") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify the old memory was superseded (valid_until set) + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + + // Find the old memory — it should be invalidated (valid_until set) + foundOld := false + foundNew := false + for _, m := range memories { + if m.Content == "Old fact about project" { + foundOld = true + // The old memory should now be invalidated (valid_until should be set) + if m.ValidUntil == "" { + t.Error("expected old memory to be invalidated (valid_until should be set)") + } + } + if m.Content == "Updated fact about project" { + foundNew = true + } + } + if !foundOld { + t.Error("expected to find old superseded memory") + } + if !foundNew { + t.Error("expected to find new extracted memory") + } +} + +func TestSessionHookCoordinator_OnEnding_ExtractsMemories(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-ending-ex", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-ending-ex", "sess-ending-ex", 1000, "assistant") + insertTestPart(t, dbPath, "part-ending-ex", "msg-ending-ex", "sess-ending-ex", 1000, + `{"type": "text", "text": "We decided to use PostgreSQL for the database"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "decision", "content": "We decided to use PostgreSQL for the database", "confidence": 0.95}, + }) + + // Create a mock Ollama server for introspection + introspectServer := newIntrospectTestServer(t) + ollamaClient, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: introspectServer.URL, + HTTPClient: introspectServer.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + OllamaClient: ollamaClient, + IntrospectModel: "test-model", + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := coord.OnEnding(ctx, "sess-ending-ex") + if err != nil { + t.Fatalf("OnEnding: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + found := false + for _, m := range memories { + if m.Content == "We decided to use PostgreSQL for the database" { + found = true + if m.Type != "decision" { + t.Errorf("expected type 'decision', got %q", m.Type) + } + break + } + } + if !found { + t.Error("expected to find extracted decision memory") + } + + // Verify a self_assessment was stored by IntrospectTranscript + saMemories, err := ms.ListAll(context.Background(), store.ListParams{Type: "self_assessment", Limit: 10}) + if err != nil { + t.Fatalf("Search self_assessment: %v", err) + } + if len(saMemories) == 0 { + t.Error("expected at least one self_assessment memory from IntrospectTranscript") + } +} + +func TestSessionHookCoordinator_OnEnding_StoresSessionEndEvent(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-end-evt", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-end-evt", "sess-end-evt", 1000, "user") + insertTestPart(t, dbPath, "part-end-evt", "msg-end-evt", "sess-end-evt", 1000, + `{"type": "text", "text": "Good session"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + // Use extractor that returns no memories (LLM degradation) so we focus on event storage + extractor := newFailingExtractionEngine(t) + + // Create a mock Ollama server that fails — triggers fallback to session_end event + introspectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + t.Cleanup(introspectServer.Close) + + ollamaClient, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: introspectServer.URL, + HTTPClient: introspectServer.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + OllamaClient: ollamaClient, + IntrospectModel: "test-model", + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := coord.OnEnding(ctx, "sess-end-evt") + if err != nil { + t.Fatalf("OnEnding: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Since Ollama is unavailable, IntrospectTranscript fails and falls back to + // a session_end event memory + allMemories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("Search all: %v", err) + } + hasSessionMemory := false + for _, m := range allMemories { + if m.Source == "introspect" || m.Source == "session_end" || m.Source == "session" { + hasSessionMemory = true + break + } + } + if !hasSessionMemory { + t.Error("expected at least one memory stored from session ending (introspect, session_end, or session)") + } +} + +func TestSessionHookCoordinator_OnEnding_IntrospectFallback(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-intro-fb", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-intro-fb", "sess-intro-fb", 1000, "user") + insertTestPart(t, dbPath, "part-intro-fb", "msg-intro-fb", "sess-intro-fb", 1000, + `{"type": "text", "text": "Fallback transcript content"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + // Use failing extractor (LLM unavailable) + extractor := newFailingExtractionEngine(t) + + // Create a mock Ollama server that returns failures — triggers IntrospectTranscript graceful degradation + introspectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + t.Cleanup(introspectServer.Close) + + ollamaClient, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: introspectServer.URL, + HTTPClient: introspectServer.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + OllamaClient: ollamaClient, + IntrospectModel: "test-model", + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := coord.OnEnding(ctx, "sess-intro-fb") + if err != nil { + t.Fatalf("OnEnding: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q (graceful degradation), got %q", ResultSuccess, result) + } + + // When introspect fails (no LLM), there should still be memories stored + // Check for self_assessment or event memory from degraded IntrospectTranscript + memories, err := ms.ListAll(context.Background(), store.ListParams{Type: "self_assessment", Limit: 10}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(memories) == 0 { + // Check for event memories as fallback + eventMemories, err := ms.ListAll(context.Background(), store.ListParams{Type: "event", Limit: 10}) + if err != nil { + t.Fatalf("ListAll events: %v", err) + } + if len(eventMemories) == 0 { + t.Error("expected at least one self_assessment or event memory from IntrospectTranscript fallback") + } + } else { + // Verify a self_assessment memory was stored from introspect + found := false + for _, m := range memories { + if m.Source == "introspect" { + found = true + break + } + } + if !found { + t.Error("expected self_assessment memory with source 'introspect'") + } + } +} + +func TestSessionHookCoordinator_OnIdle_NilExtractionEngine(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-nil-ext", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-nil-ext", "sess-nil-ext", 1000, "user") + insertTestPart(t, dbPath, "part-nil-ext", "msg-nil-ext", "sess-nil-ext", 1000, + `{"type": "text", "text": "Content without extraction"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + // ExtractionEngine is nil — extraction should be skipped gracefully + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-nil-ext") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // No extraction memories should have been stored + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + // No extraction memories should have been stored from session extraction + var sessionMemCount int + for _, m := range memories { + if m.Source == "session" { + sessionMemCount++ + } + } + if sessionMemCount != 0 { + t.Errorf("expected no session-sourced memories with nil extraction engine, got %d", sessionMemCount) + } +} + +func TestSessionHookCoordinator_OnEnding_NilExtractionEngine(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-nil-end", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-nil-end", "sess-nil-end", 1000, "user") + insertTestPart(t, dbPath, "part-nil-end", "msg-nil-end", "sess-nil-end", 1000, + `{"type": "text", "text": "Ending content without extraction"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + // ExtractionEngine is nil — extraction should be skipped. + // OllamaClient is nil — IntrospectTranscript falls back to degraded storage + // (produces a self_assessment memory without LLM call). + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnEnding(context.Background(), "sess-nil-end") + if err != nil { + t.Fatalf("OnEnding: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // IntrospectTranscript is called even with nil OllamaClient — it falls back + // to degraded storage producing a self_assessment memory with a plain-text summary. + saMemories, err := ms.ListAll(context.Background(), store.ListParams{Type: "self_assessment", Limit: 10}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(saMemories) == 0 { + t.Error("expected at least one self_assessment memory from IntrospectTranscript degraded storage (nil OllamaClient)") + } + // Verify the degraded memory contains the session ID + found := false + for _, m := range saMemories { + if strings.Contains(m.Content, "sess-nil-end") { + found = true + break + } + } + if !found { + t.Error("expected degraded self_assessment memory to contain session ID 'sess-nil-end'") + } +} + +// TestSessionHookCoordinator_OnEnding_NilOllamaClient_ProducesDegradedIntrospection +// verifies the fix for issue ll-m8knf-vup88: OnEnding must call IntrospectTranscript +// even when OllamaClient is nil. IntrospectTranscript has its own nil-client graceful +// degradation — it produces a plain-text self_assessment memory. The bug was that +// OnEnding guarded the call with c.ollamaClient != nil, bypassing this degradation +// and producing zero introspection memories for sessions with valid transcripts. +func TestSessionHookCoordinator_OnEnding_NilOllamaClient_ProducesDegradedIntrospection(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-nil-ollama", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-nil-ollama", "sess-nil-ollama", 1000, "user") + insertTestPart(t, dbPath, "part-nil-ollama", "msg-nil-ollama", "sess-nil-ollama", 1000, + `{"type": "text", "text": "The project uses Go 1.22 for robust concurrency"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + // Set up an extraction engine working normally + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "The project uses Go 1.22", "confidence": 0.9}, + }) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + // OllamaClient is nil — IntrospectTranscript must still be called + // and must produce a degraded self_assessment memory + }) + + result, err := coord.OnEnding(context.Background(), "sess-nil-ollama") + if err != nil { + t.Fatalf("OnEnding: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify that IntrospectTranscript produced a degraded self_assessment memory + saMemories, err := ms.ListAll(context.Background(), store.ListParams{Type: "self_assessment", Limit: 10}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(saMemories) == 0 { + t.Error("expected at least one self_assessment memory from IntrospectTranscript degraded path (nil OllamaClient)") + } + + // Verify the degraded memory contains the session ID + found := false + for _, m := range saMemories { + if strings.Contains(m.Content, "sess-nil-ollama") { + found = true + if m.Source != "introspect" { + t.Errorf("expected source 'introspect', got %q", m.Source) + } + break + } + } + if !found { + t.Error("expected degraded self_assessment memory to contain session ID") + } + + // Also verify extraction memories were still stored + sessionMemories, err := ms.ListAll(context.Background(), store.ListParams{Type: "fact", Limit: 10}) + if err != nil { + t.Fatalf("ListAll facts: %v", err) + } + if len(sessionMemories) == 0 { + t.Error("expected at least one fact memory from extraction") + } +} + +func TestSessionHookCoordinator_OnIdle_WithAdapter_VerifiesMemoriesStored(t *testing.T) { + // Updated version of the existing test that now verifies memories are stored + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-idle-v2", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-idle-v2", "sess-idle-v2", 1000, "user") + insertTestPart(t, dbPath, "part-idle-v2", "msg-idle-v2", "sess-idle-v2", 1000, + `{"type": "text", "text": "Idle transcript content"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + // Set up extraction engine that returns a memory + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "Idle transcript content", "confidence": 0.8}, + }) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-idle-v2") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify that a memory was actually stored in the test store + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + var sessionMemCount int + for _, m := range memories { + if m.Source == "session" { + sessionMemCount++ + } + } + if sessionMemCount == 0 { + t.Error("expected at least one session-sourced memory to be stored after OnIdle with extraction") + } +} + +func TestSessionHookCoordinator_OnEnding_WithAdapter_VerifiesMemoriesStored(t *testing.T) { + // Updated version of the existing test that now verifies memories are stored + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-ending-v2", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-ending-v2", "sess-ending-v2", 1000, "assistant") + insertTestPart(t, dbPath, "part-ending-v2", "msg-ending-v2", "sess-ending-v2", 1000, + `{"type": "text", "text": "Ending transcript content"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + // Set up extraction engine that returns a memory + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "Ending transcript content", "confidence": 0.85}, + }) + + // Set up a mock Ollama server for introspection + introspectServer := newIntrospectTestServer(t) + ollamaClient, err := ollama.NewOllamaClient(ollama.OllamaClientConfig{ + BaseURL: introspectServer.URL, + HTTPClient: introspectServer.Client(), + }) + if err != nil { + t.Fatalf("NewOllamaClient: %v", err) + } + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + OllamaClient: ollamaClient, + IntrospectModel: "test-model", + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := coord.OnEnding(ctx, "sess-ending-v2") + if err != nil { + t.Fatalf("OnEnding: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify that a memory was actually stored in the test store + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + var sessionOrIntrospectCount int + for _, m := range memories { + if m.Source == "session" || m.Source == "introspect" { + sessionOrIntrospectCount++ + } + } + if sessionOrIntrospectCount == 0 { + t.Error("expected at least one session or introspect memory to be stored after OnEnding with extraction") + } +} + func TestSessionHookCoordinator_OnEndingWithIntrospect(t *testing.T) { dbPath := createTestOpenCodeDB(t) insertTestSession(t, dbPath, "sess-end-auto", 1000, 2000, nil, "/work") @@ -1074,4 +2006,69 @@ func TestSessionHookCoordinator_OnEndingWithIntrospect_IntrospectionFailure(t *t if memoryID != "" { t.Errorf("expected empty memory ID on introspection failure, got %q", memoryID) } -} \ No newline at end of file +} + +// TestSessionHookCoordinator_OnIdle_WithEmbedding_VerifiesMemoriesHaveEmbeddings +// verifies that the embedding path in extractMemories is exercised. When +// an EmbeddingEngine is configured, extracted memories should have embedding +// vectors passed to store.Add (stored alongside the memory). This test +// exercises the previously-untested embedder != nil code path in +// extractMemories (session.go:677-683). +func TestSessionHookCoordinator_OnIdle_WithEmbedding_VerifiesMemoriesHaveEmbeddings(t *testing.T) { + dbPath := createTestOpenCodeDB(t) + insertTestSession(t, dbPath, "sess-embed", 1000, 2000, nil, "/work") + insertTestMessage(t, dbPath, "msg-embed", "sess-embed", 1000, "user") + insertTestPart(t, dbPath, "part-embed", "msg-embed", "sess-embed", 1000, + `{"type": "text", "text": "The system uses SQLite for persistence"}`) + + adapter, err := NewOpenCodeAdapter(dbPath) + if err != nil { + t.Fatalf("NewOpenCodeAdapter: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + + extractor, _ := newTestExtractionEngine(t, []map[string]any{ + {"type": "fact", "content": "The system uses SQLite for persistence", "confidence": 0.85}, + }) + + embedder, _ := newTestEmbeddingEngine(t) + + ms := newTestStore(t) + coord, err := NewSessionHookCoordinator(SessionHookConfig{ + Store: ms, + Adapter: adapter, + ExtractionEngine: extractor, + Embedding: embedder, + }) + if err != nil { + t.Fatalf("NewSessionHookCoordinator: %v", err) + } + + result, err := coord.OnIdle(context.Background(), "sess-embed") + if err != nil { + t.Fatalf("OnIdle: %v", err) + } + if result != ResultSuccess { + t.Errorf("expected %q, got %q", ResultSuccess, result) + } + + // Verify that at least one memory was stored via the extraction+embedding path + memories, err := ms.ListAll(context.Background(), store.ListParams{Limit: 50}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + + found := false + for _, m := range memories { + if m.Content == "The system uses SQLite for persistence" && m.Source == "session" { + found = true + if len(m.Embedding) == 0 { + t.Error("expected extracted memory to have non-zero embedding vector, but Embedding is empty") + } + break + } + } + if !found { + t.Error("expected to find extracted memory 'The system uses SQLite for persistence' with source 'session'") + } +} diff --git a/internal/store/helpers.go b/internal/store/helpers.go index 328f70a..3cd6f4c 100644 --- a/internal/store/helpers.go +++ b/internal/store/helpers.go @@ -67,9 +67,9 @@ func escapeLike(query string) string { return s } -// bytesToVec decodes a packed float32 byte slice into a []float32. +// BytesToVec decodes a packed float32 byte slice into a []float32. // Matches Python's struct.unpack(f"{dim}f", data). -func bytesToVec(data []byte) []float32 { +func BytesToVec(data []byte) []float32 { if len(data) == 0 { return nil } @@ -81,9 +81,9 @@ func bytesToVec(data []byte) []float32 { return result } -// vecToBytes encodes a []float32 into packed little-endian bytes. +// VecToBytes encodes a []float32 into packed little-endian bytes. // Matches Python's struct.pack(f"{dim}f", *vec). -func vecToBytes(vec []float32) []byte { +func VecToBytes(vec []float32) []byte { buf := make([]byte, len(vec)*4) for i, v := range vec { binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(v)) diff --git a/internal/store/store.go b/internal/store/store.go index 956628f..c94ce01 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -703,7 +703,7 @@ func (ms *MemoryStore) SearchByEmbedding(ctx context.Context, queryVec []float32 } func (ms *MemoryStore) searchByEmbeddingVec(ctx context.Context, queryVec []float32, validOnly bool, limit int, threshold float64) ([]*ScoredMemory, error) { - queryBytes := vecToBytes(queryVec) + queryBytes := VecToBytes(queryVec) multipliers := []int{3, 10, 50, 0} var scored []*ScoredMemory @@ -866,7 +866,7 @@ func (ms *MemoryStore) searchByEmbeddingBrute(ctx context.Context, queryVec []fl if err := rows.Scan(&id, &embBytes); err != nil { return nil, fmtErr("search_by_embedding: brute scan: %w", err) } - vec := bytesToVec(embBytes) + vec := BytesToVec(embBytes) if len(vec) != len(queryVec) { continue } @@ -1251,7 +1251,7 @@ func (ms *MemoryStore) ConsolidateDuplicates(ctx context.Context, threshold floa if err := rows.Scan(&id, &content, &embBytes); err != nil { return nil, fmtErr("consolidate_duplicates: scan: %w", err) } - vec := bytesToVec(embBytes) + vec := BytesToVec(embBytes) entries = append(entries, entry{id: id, content: content, emb: vec}) } if rows.Err() != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 977a53e..9d02c01 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -802,8 +802,8 @@ func TestMemoryStore_ConsolidateDuplicates(t *testing.T) { vec1[i] = 0.1 vec2[i] = 0.1 } - emb1 := vecToBytes(vec1) - emb2 := vecToBytes(vec2) + emb1 := VecToBytes(vec1) + emb2 := VecToBytes(vec2) ms.Add(ctx, AddParams{Type: "fact", Content: "fact 1", Embedding: emb1}) ms.Add(ctx, AddParams{Type: "fact", Content: "fact 2", Embedding: emb2}) @@ -993,7 +993,7 @@ func TestMemoryStore_SearchByEmbedding_BruteForce(t *testing.T) { for i := range vec { vec[i] = 0.1 } - emb := vecToBytes(vec) + emb := VecToBytes(vec) ms.Add(ctx, AddParams{Type: "fact", Content: "memory with embedding", Embedding: emb}) @@ -1133,7 +1133,7 @@ func TestMemoryStore_GetEmbeddingsWithTypes(t *testing.T) { for i := range vec { vec[i] = 0.1 } - emb := vecToBytes(vec) + emb := VecToBytes(vec) ms.Add(ctx, AddParams{Type: "fact", Content: "with embedding", Embedding: emb}) results, err := ms.GetEmbeddingsWithTypes(ctx, 100) @@ -1432,7 +1432,7 @@ func TestSearchByEmbedding_DefaultLimit(t *testing.T) { ms.Add(ctx, AddParams{ Type: "fact", Content: fmt.Sprintf("memory %d", i), - Embedding: vecToBytes(vec), + Embedding: VecToBytes(vec), }) } @@ -1583,7 +1583,7 @@ func TestGetEmbeddingsWithTypes_ZeroLimit_NoLimit(t *testing.T) { for i := range vec { vec[i] = 0.1 } - emb := vecToBytes(vec) + emb := VecToBytes(vec) ms.Add(ctx, AddParams{Type: "fact", Content: "m1", Embedding: emb}) ms.Add(ctx, AddParams{Type: "fact", Content: "m2", Embedding: emb}) ms.Add(ctx, AddParams{Type: "fact", Content: "m3", Embedding: emb}) @@ -1606,7 +1606,7 @@ func TestGetEmbeddingsWithTypes_NegativeLimit_DefaultLimit(t *testing.T) { for i := range vec { vec[i] = 0.1 } - emb := vecToBytes(vec) + emb := VecToBytes(vec) ms.Add(ctx, AddParams{Type: "fact", Content: "m1", Embedding: emb}) results, err := ms.GetEmbeddingsWithTypes(ctx, -1)