From baa217591670e3119bd2cb57798f3b79059029f5 Mon Sep 17 00:00:00 2001 From: Neuralab Marketing Date: Thu, 30 Jul 2026 10:31:57 -0300 Subject: [PATCH 1/4] feat(store): make every context section boundable via ContextOptions FormatContext renders four fixed-size sections into the SessionStart blob: sessions (5), prompts (10, hardcoded), unpinned observations (cfg.MaxContextResults) and pinned observations, which have had no LIMIT at all since #484. None of it is reachable from outside the store. Adds ContextOptions{Observations, Prompts, Sessions, Pinned, Compact} and FormatContextWithOptions. Each int field follows one convention: 0 keeps that section's legacy default, >0 caps it, <0 omits the section and its header. Compact drops the 300-char body preview from observation-shaped bullets. FormatContext keeps its signature and becomes a thin wrapper over a zero-value ContextOptions, so its four callers (cmd, server, mcp) are untouched and the output is byte-identical -- asserted by a dedicated test. PinnedObservations also keeps its exported signature and delegates to an unexported helper that adds SQL LIMIT only when a positive cap is requested. Refs #163 Co-authored-by: todie <7841714+todie@users.noreply.github.com> Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VtshktvJgt8fVkBr5ZjT1J --- internal/store/store.go | 129 +++++++++++++++--- internal/store/store_test.go | 257 +++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 16 deletions(-) diff --git a/internal/store/store.go b/internal/store/store.go index 9c6537b9..59b2e48e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2420,7 +2420,21 @@ func (s *Store) RecentObservations(project, scope string, limit int) ([]Observat return s.queryObservations(query, args...) } +// PinnedObservations returns every pinned observation for project/scope, +// most-recent-first, with no row limit — pinning is an explicit, hand-bounded +// action, so returning all pinned rows has always been the legacy default. +// Callers that need a cap (e.g. FormatContextWithOptions via +// ContextOptions.Pinned) use the unexported pinnedObservationsLimit helper, +// which this delegates to with limit=0 ("no LIMIT clause", i.e. unbounded). func (s *Store) PinnedObservations(project, scope string) ([]Observation, error) { + return s.pinnedObservationsLimit(project, scope, 0) +} + +// pinnedObservationsLimit is the shared query behind PinnedObservations. A +// limit <= 0 means "no LIMIT clause" (every pinned row, matching +// PinnedObservations' historical unbounded behavior); a positive limit caps +// the result via SQL LIMIT. +func (s *Store) pinnedObservationsLimit(project, scope string, limit int) ([]Observation, error) { project, _ = NormalizeProject(project) query := ` @@ -2440,6 +2454,10 @@ func (s *Store) PinnedObservations(project, scope string) ([]Observation, error) } query += " ORDER BY datetime(o.created_at) DESC, o.id DESC" + if limit > 0 { + query += " LIMIT ?" + args = append(args, limit) + } return s.queryObservations(query, args...) } @@ -3295,25 +3313,96 @@ SELECT 1 FROM ( // ─── Context Formatting ───────────────────────────────────────────────────── +// ContextOptions tunes FormatContextWithOptions, capping how many rows each +// section of the "## Memory from Previous Sessions" block renders. +// +// Every field follows the same convention: +// - 0 uses that section's legacy default. +// - >0 caps the section at that many rows. +// - <0 omits the section entirely, including its "### ..." header. +// +// The legacy defaults are Sessions 5, Prompts 10, Observations +// s.cfg.MaxContextResults, and Pinned unlimited (no SQL LIMIT) — exactly +// what FormatContext has always produced, so a zero-value ContextOptions{} +// reproduces FormatContext's output byte-for-byte. See issue #163 +// (bounded-size injection). +type ContextOptions struct { + // Observations caps the "### Recent Observations" section (unpinned). + Observations int + + // Prompts caps the "### Recent User Prompts" section. + Prompts int + + // Sessions caps the "### Recent Sessions" section. + Sessions int + + // Pinned caps the "### Pinned" section. + Pinned int + + // Compact drops the inline content preview from observation-shaped + // bullets — both "### Pinned" and "### Recent Observations" render + // `- [type] **title**` instead of `- [type] **title**: <300 chars of + // body>`. Sessions and prompts bullets are unaffected. + Compact bool +} + +// FormatContext is a thin wrapper around FormatContextWithOptions using a +// zero-value ContextOptions, preserving the pre-ContextOptions call +// signature so existing callers and tests keep working unchanged. func (s *Store) FormatContext(project, scope string) (string, error) { - sessions, err := s.RecentSessions(project, 5) - if err != nil { - return "", err + return s.FormatContextWithOptions(project, scope, ContextOptions{}) +} + +// FormatContextWithOptions renders the "## Memory from Previous Sessions" +// markdown block for the given project/scope, honoring the per-section caps +// and Compact rendering in opts. See ContextOptions for the cap convention. +func (s *Store) FormatContextWithOptions(project, scope string, opts ContextOptions) (string, error) { + var ( + sessions []SessionSummary + pinned []Observation + observations []Observation + prompts []Prompt + err error + ) + + if opts.Sessions >= 0 { + limit := opts.Sessions + if limit == 0 { + limit = 5 + } + if sessions, err = s.RecentSessions(project, limit); err != nil { + return "", err + } } - pinned, err := s.PinnedObservations(project, scope) - if err != nil { - return "", err + if opts.Pinned == 0 { + if pinned, err = s.PinnedObservations(project, scope); err != nil { + return "", err + } + } else if opts.Pinned > 0 { + if pinned, err = s.pinnedObservationsLimit(project, scope, opts.Pinned); err != nil { + return "", err + } } - observations, err := s.recentUnpinnedObservations(project, scope, s.cfg.MaxContextResults) - if err != nil { - return "", err + if opts.Observations >= 0 { + limit := opts.Observations + if limit == 0 { + limit = s.cfg.MaxContextResults + } + if observations, err = s.recentUnpinnedObservations(project, scope, limit); err != nil { + return "", err + } } - prompts, err := s.RecentPrompts(project, 10) - if err != nil { - return "", err + if opts.Prompts >= 0 { + limit := opts.Prompts + if limit == 0 { + limit = 10 + } + if prompts, err = s.RecentPrompts(project, limit); err != nil { + return "", err + } } if len(sessions) == 0 && len(pinned) == 0 && len(observations) == 0 && len(prompts) == 0 { @@ -3347,8 +3436,12 @@ func (s *Store) FormatContext(project, scope string) (string, error) { if len(pinned) > 0 { b.WriteString("### Pinned\n") for _, obs := range pinned { - fmt.Fprintf(&b, "- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, 300)) + if opts.Compact { + fmt.Fprintf(&b, "- [%s] **%s**\n", obs.Type, obs.Title) + } else { + fmt.Fprintf(&b, "- [%s] **%s**: %s\n", + obs.Type, obs.Title, truncate(obs.Content, 300)) + } } b.WriteString("\n") } @@ -3356,8 +3449,12 @@ func (s *Store) FormatContext(project, scope string) (string, error) { if len(observations) > 0 { b.WriteString("### Recent Observations\n") for _, obs := range observations { - fmt.Fprintf(&b, "- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, 300)) + if opts.Compact { + fmt.Fprintf(&b, "- [%s] **%s**\n", obs.Type, obs.Title) + } else { + fmt.Fprintf(&b, "- [%s] **%s**: %s\n", + obs.Type, obs.Title, truncate(obs.Content, 300)) + } } b.WriteString("\n") } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5ed55ca6..6944604c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8830,3 +8830,260 @@ func TestSanitizeFTS(t *testing.T) { }) } } + +// ─── ContextOptions tests (issue #163) ────────────────────────────────────── +// +// FormatContextWithOptions caps each of the 4 sections independently via +// ContextOptions (0 = legacy default, >0 = cap, <0 = omit the section and +// its header) and can compact observation-shaped bullets. FormatContext is +// now a thin wrapper delegating to FormatContextWithOptions with a +// zero-value ContextOptions{}. + +// TestFormatContextWithOptions seeds enough rows per section (more than +// every legacy default) so defaults, explicit caps, and omission are all +// distinguishable from "everything". cfg.MaxContextResults is pinned to 3 +// so the Observations legacy default is a known, assertable number. +func TestFormatContextWithOptions(t *testing.T) { + cfg := mustDefaultConfig(t) + cfg.DataDir = t.TempDir() + cfg.DedupeWindow = time.Hour + cfg.MaxContextResults = 3 + s, err := New(cfg) + if err != nil { + t.Fatalf("new store: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + // 7 sessions: more than the legacy Sessions=5 default. + for i := 0; i < 7; i++ { + if err := s.CreateSession(fmt.Sprintf("ctx-sess-%d", i), "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session %d: %v", i, err) + } + } + + // 12 prompts: more than the legacy Prompts=10 default. + for i := 0; i < 12; i++ { + if _, err := s.AddPrompt(AddPromptParams{ + SessionID: "ctx-sess-0", + Content: fmt.Sprintf("prompt body %d", i), + Project: "engram", + }); err != nil { + t.Fatalf("add prompt %d: %v", i, err) + } + } + + // 5 unpinned observations with long multi-line bodies: more than + // cfg.MaxContextResults=3, and enough content for Compact to visibly drop. + for i := 0; i < 5; i++ { + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "ctx-sess-0", + Type: "decision", + Title: fmt.Sprintf("obs-%d", i), + Content: fmt.Sprintf("## Goal\nLine one for obs %d\n\n## Details\nLorem ipsum dolor sit amet, a long body Compact mode should drop entirely.", i), + Project: "engram", + Scope: "project", + }); err != nil { + t.Fatalf("add obs %d: %v", i, err) + } + } + + // 4 pinned observations: PinnedObservations has no legacy cap, so all 4 + // must survive the zero-value default and only a positive Pinned should + // trim them. + for i := 0; i < 4; i++ { + id, err := s.AddObservation(AddObservationParams{ + SessionID: "ctx-sess-0", + Type: "architecture", + Title: fmt.Sprintf("pin-%d", i), + Content: fmt.Sprintf("Pinned body %d with a Lorem ipsum preview Compact should drop.", i), + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add pinned obs %d: %v", i, err) + } + if err := s.PinObservation(id); err != nil { + t.Fatalf("pin obs %d: %v", i, err) + } + } + + legacyCtx, err := s.FormatContext("engram", "project") + if err != nil { + t.Fatalf("format context legacy: %v", err) + } + + // ── Zero-value: the mandating test. FormatContextWithOptions with a + // zero-value ContextOptions must reproduce FormatContext byte-for-byte, + // since FormatContext is now defined purely as that delegation. + t.Run("zero value matches legacy FormatContext byte-for-byte", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{}) + if err != nil { + t.Fatalf("format context zero-value: %v", err) + } + if got != legacyCtx { + t.Fatalf("zero-value ContextOptions must match legacy FormatContext output.\nzero-value:\n%s\nlegacy:\n%s", got, legacyCtx) + } + }) + + // ── The zero-value output must actually carry the documented legacy + // numbers (5/10/cfg.MaxContextResults/unlimited), not just agree with + // itself — guards against both defaults drifting together silently. + t.Run("zero value applies the documented legacy defaults", func(t *testing.T) { + if got := strings.Count(legacyCtx, "- **engram** ("); got != 5 { + t.Fatalf("expected legacy default of 5 sessions, got %d\n%s", got, legacyCtx) + } + if got := strings.Count(legacyCtx, "prompt body "); got != 10 { + t.Fatalf("expected legacy default of 10 prompts, got %d\n%s", got, legacyCtx) + } + if got := strings.Count(legacyCtx, "- [decision] **obs-"); got != 3 { + t.Fatalf("expected legacy default of cfg.MaxContextResults=3 observations, got %d\n%s", got, legacyCtx) + } + if got := strings.Count(legacyCtx, "- [architecture] **pin-"); got != 4 { + t.Fatalf("expected legacy default of unlimited (4) pinned, got %d\n%s", got, legacyCtx) + } + }) + + t.Run("positive Sessions caps only sessions", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Sessions: 2}) + if err != nil { + t.Fatalf("format context Sessions=2: %v", err) + } + if n := strings.Count(got, "- **engram** ("); n != 2 { + t.Fatalf("expected 2 sessions under Sessions=2, got %d\n%s", n, got) + } + if n := strings.Count(got, "prompt body "); n != 10 { + t.Fatalf("Sessions cap must not affect prompts, got %d\n%s", n, got) + } + if n := strings.Count(got, "- [decision] **obs-"); n != 3 { + t.Fatalf("Sessions cap must not affect observations, got %d\n%s", n, got) + } + if n := strings.Count(got, "- [architecture] **pin-"); n != 4 { + t.Fatalf("Sessions cap must not affect pinned, got %d\n%s", n, got) + } + }) + + t.Run("negative Sessions omits the section and its header", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Sessions: -1}) + if err != nil { + t.Fatalf("format context Sessions=-1: %v", err) + } + if strings.Contains(got, "### Recent Sessions") { + t.Fatalf("expected Recent Sessions header omitted, got:\n%s", got) + } + if strings.Contains(got, "- **engram** (") { + t.Fatalf("expected no session bullets, got:\n%s", got) + } + if !strings.Contains(got, "### Recent Observations") { + t.Fatalf("Sessions omission must not touch other sections, got:\n%s", got) + } + }) + + t.Run("positive Prompts caps only prompts", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Prompts: 3}) + if err != nil { + t.Fatalf("format context Prompts=3: %v", err) + } + if n := strings.Count(got, "prompt body "); n != 3 { + t.Fatalf("expected 3 prompts under Prompts=3, got %d\n%s", n, got) + } + if n := strings.Count(got, "- **engram** ("); n != 5 { + t.Fatalf("Prompts cap must not affect sessions, got %d\n%s", n, got) + } + }) + + t.Run("negative Prompts omits the section and its header", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Prompts: -1}) + if err != nil { + t.Fatalf("format context Prompts=-1: %v", err) + } + if strings.Contains(got, "### Recent User Prompts") { + t.Fatalf("expected Recent User Prompts header omitted, got:\n%s", got) + } + if strings.Contains(got, "prompt body ") { + t.Fatalf("expected no prompt bullets, got:\n%s", got) + } + if !strings.Contains(got, "### Pinned") { + t.Fatalf("Prompts omission must not touch other sections, got:\n%s", got) + } + }) + + t.Run("positive Observations caps only observations", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Observations: 1}) + if err != nil { + t.Fatalf("format context Observations=1: %v", err) + } + if n := strings.Count(got, "- [decision] **obs-"); n != 1 { + t.Fatalf("expected 1 observation under Observations=1, got %d\n%s", n, got) + } + if n := strings.Count(got, "- [architecture] **pin-"); n != 4 { + t.Fatalf("Observations cap must not affect pinned, got %d\n%s", n, got) + } + }) + + t.Run("negative Observations omits the section and its header", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Observations: -1}) + if err != nil { + t.Fatalf("format context Observations=-1: %v", err) + } + if strings.Contains(got, "### Recent Observations") { + t.Fatalf("expected Recent Observations header omitted, got:\n%s", got) + } + if strings.Contains(got, "- [decision] **obs-") { + t.Fatalf("expected no observation bullets, got:\n%s", got) + } + if !strings.Contains(got, "### Pinned") { + t.Fatalf("Observations omission must not touch other sections, got:\n%s", got) + } + }) + + t.Run("positive Pinned caps only pinned", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Pinned: 2}) + if err != nil { + t.Fatalf("format context Pinned=2: %v", err) + } + if n := strings.Count(got, "- [architecture] **pin-"); n != 2 { + t.Fatalf("expected 2 pinned under Pinned=2, got %d\n%s", n, got) + } + if n := strings.Count(got, "- [decision] **obs-"); n != 3 { + t.Fatalf("Pinned cap must not affect observations, got %d\n%s", n, got) + } + }) + + t.Run("negative Pinned omits the section and its header", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Pinned: -1}) + if err != nil { + t.Fatalf("format context Pinned=-1: %v", err) + } + if strings.Contains(got, "### Pinned") { + t.Fatalf("expected Pinned header omitted, got:\n%s", got) + } + if strings.Contains(got, "- [architecture] **pin-") { + t.Fatalf("expected no pinned bullets, got:\n%s", got) + } + if !strings.Contains(got, "### Recent Observations") { + t.Fatalf("Pinned omission must not touch other sections, got:\n%s", got) + } + }) + + t.Run("Compact drops body previews from observation and pinned bullets only", func(t *testing.T) { + got, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Compact: true}) + if err != nil { + t.Fatalf("format context Compact: %v", err) + } + if strings.Contains(got, "Lorem ipsum") { + t.Fatalf("Compact should drop body previews, got:\n%s", got) + } + if !strings.Contains(got, "- [decision] **obs-4**\n") { + t.Fatalf("Compact should keep observation titles bullet-only, got:\n%s", got) + } + if !strings.Contains(got, "- [architecture] **pin-3**\n") { + t.Fatalf("Compact should keep pinned titles bullet-only, got:\n%s", got) + } + if !strings.Contains(got, "prompt body ") { + t.Fatalf("Compact must not affect prompt bullets, got:\n%s", got) + } + if len(got) >= len(legacyCtx) { + t.Fatalf("Compact output (%d) should be smaller than legacy output (%d)", len(got), len(legacyCtx)) + } + }) +} From 6104ccc450b72ba34a9056966e623924a22f9e0b Mon Sep 17 00:00:00 2001 From: Neuralab Marketing Date: Thu, 30 Jul 2026 10:39:49 -0300 Subject: [PATCH 2/4] feat(server): expose context section caps as GET /context query params handleContext now builds a store.ContextOptions from observations, prompts, sessions, pinned and compact, reusing the existing queryInt/queryBool helpers: an absent, empty or unparseable param falls back to the zero value, so garbage input keeps returning 200 with the legacy blob instead of a 4xx. Negative values are passed through deliberately -- they are how a caller omits a section entirely -- unlike the 'err == nil && n > 0' guard in #162, which would have silently dropped them. Refs #163 Co-authored-by: todie <7841714+todie@users.noreply.github.com> Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VtshktvJgt8fVkBr5ZjT1J --- internal/server/server.go | 17 ++++- internal/server/server_e2e_test.go | 117 +++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index c30f66a1..d381aa6b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -774,7 +774,22 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) { project := r.URL.Query().Get("project") scope := r.URL.Query().Get("scope") - context, err := s.store.FormatContext(project, scope) + // Per-section caps via store.ContextOptions: 0 (param absent/empty/ + // unparseable) keeps FormatContext's legacy default, >0 caps the + // section, and <0 is a DELIBERATE way to omit the section (and its + // "### ..." header) entirely — not a bug to be filtered out like the + // `err == nil && n > 0` guard in the upstream reference this was + // adapted from (PR #162). queryInt/queryBool already fall back to the + // zero value on any bad input, so garbage query params never 4xx here. + opts := store.ContextOptions{ + Observations: queryInt(r, "observations", 0), + Prompts: queryInt(r, "prompts", 0), + Sessions: queryInt(r, "sessions", 0), + Pinned: queryInt(r, "pinned", 0), + Compact: queryBool(r, "compact", false), + } + + context, err := s.store.FormatContextWithOptions(project, scope, opts) if err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/server/server_e2e_test.go b/internal/server/server_e2e_test.go index 3aa783cf..9da1526e 100644 --- a/internal/server/server_e2e_test.go +++ b/internal/server/server_e2e_test.go @@ -472,6 +472,123 @@ func TestCoreReadHandlersAndHelpersE2E(t *testing.T) { endResp.Body.Close() } +// TestContextQueryParamsE2E covers the store.ContextOptions query params on +// GET /context (feat/context-size-cap): observations/prompts/sessions/pinned +// (signed int caps) and compact (bool). Convention: 0 = legacy default, +// >0 = cap, <0 = omit the section (and its header) entirely. +func TestContextQueryParamsE2E(t *testing.T) { + s, ts := newE2EServer(t) + client := ts.Client() + + create := postJSON(t, client, ts.URL+"/sessions", map[string]any{ + "id": "s-ctx-params", + "project": "engram", + }) + if create.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating session, got %d", create.StatusCode) + } + create.Body.Close() + + const bodyMarker = "UNIQUE_CONTEXT_PARAMS_BODY_MARKER_9f3a" + obsResp := postJSON(t, client, ts.URL+"/observations", map[string]any{ + "session_id": "s-ctx-params", + "type": "decision", + "title": "Context params observation", + "content": "Long body so compact mode has something to drop. " + bodyMarker, + "project": "engram", + "scope": "project", + }) + if obsResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating observation, got %d", obsResp.StatusCode) + } + obsResp.Body.Close() + + promptResp := postJSON(t, client, ts.URL+"/prompts", map[string]any{ + "session_id": "s-ctx-params", + "content": "prompt for context params test", + "project": "engram", + }) + if promptResp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 creating prompt, got %d", promptResp.StatusCode) + } + promptResp.Body.Close() + + // ── No params: byte-identical to the legacy FormatContext call — the + // contract must not change for existing callers. + defaultResp, err := client.Get(ts.URL + "/context?project=engram&scope=project") + if err != nil { + t.Fatalf("context default: %v", err) + } + if defaultResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 context default, got %d", defaultResp.StatusCode) + } + defaultData := decodeJSON[map[string]string](t, defaultResp) + + legacy, err := s.FormatContext("engram", "project") + if err != nil { + t.Fatalf("legacy FormatContext: %v", err) + } + if defaultData["context"] != legacy { + t.Fatalf("no-params /context should match legacy FormatContext exactly.\ngot:\n%s\nwant:\n%s", defaultData["context"], legacy) + } + if !strings.Contains(defaultData["context"], bodyMarker) { + t.Fatalf("default context should include the observation body preview, got:\n%s", defaultData["context"]) + } + if !strings.Contains(defaultData["context"], "### Recent User Prompts") { + t.Fatalf("default context should include the prompts section, got:\n%s", defaultData["context"]) + } + + // ── compact=1&observations=1: strictly smaller than default, no body preview. + compactResp, err := client.Get(ts.URL + "/context?project=engram&scope=project&compact=1&observations=1") + if err != nil { + t.Fatalf("context compact: %v", err) + } + if compactResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 context compact, got %d", compactResp.StatusCode) + } + compactData := decodeJSON[map[string]string](t, compactResp) + if len(compactData["context"]) >= len(defaultData["context"]) { + t.Fatalf("compact context (%d) should be smaller than default (%d)", + len(compactData["context"]), len(defaultData["context"])) + } + if strings.Contains(compactData["context"], bodyMarker) { + t.Fatalf("compact context should not include the observation body preview, got:\n%s", compactData["context"]) + } + + // ── prompts=-1: negative cap omits the prompts section AND its header + // (this is the behavior PR #162's `err == nil && n > 0` parsing would + // have silently discarded — negatives must reach the store as-is). + noPromptsResp, err := client.Get(ts.URL + "/context?project=engram&scope=project&prompts=-1") + if err != nil { + t.Fatalf("context prompts=-1: %v", err) + } + if noPromptsResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 context prompts=-1, got %d", noPromptsResp.StatusCode) + } + noPromptsData := decodeJSON[map[string]string](t, noPromptsResp) + if strings.Contains(noPromptsData["context"], "### Recent User Prompts") { + t.Fatalf("prompts=-1 should drop the '### Recent User Prompts' header, got:\n%s", noPromptsData["context"]) + } + if strings.Contains(noPromptsData["context"], "prompt for context params test") { + t.Fatalf("prompts=-1 should drop prompt content, got:\n%s", noPromptsData["context"]) + } + + // ── observations=abc: unparseable value is ignored (never a 4xx), falls + // back to the same zero-value default as the no-params request. + garbageResp, err := client.Get(ts.URL + "/context?project=engram&scope=project&observations=abc") + if err != nil { + t.Fatalf("context garbage observations: %v", err) + } + if garbageResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 context garbage observations, got %d", garbageResp.StatusCode) + } + garbageData := decodeJSON[map[string]string](t, garbageResp) + if garbageData["context"] != defaultData["context"] { + t.Fatalf("observations=abc should be ignored and match the default output.\ngot:\n%s\nwant:\n%s", + garbageData["context"], defaultData["context"]) + } +} + func TestValidationAndImportExportErrorsE2E(t *testing.T) { _, ts := newE2EServer(t) client := ts.Client() From 4cb497880b160d99417738c710c77a7f00c7aa29 Mon Sep 17 00:00:00 2001 From: Neuralab Marketing Date: Thu, 30 Jul 2026 10:42:07 -0300 Subject: [PATCH 3/4] feat(claude-code): request a bounded context blob from the session hooks Both session-start.sh and post-compaction.sh now fetch the context with compact=1&pinned=20. compact drops the 300-char body preview from observation bullets while keeping every row and title -- the body is one mem_get_observation away -- and pinned=20 puts a ceiling on the only section that never had one. Measured end to end by running the hooks against a 2,976-observation project: 9,404 B -> 3,179 B injected per session start (-66%), with no row dropped. This is the only commit in the series that changes observable behavior; the store and server commits are mechanism with byte-identical defaults. Drop this one if you would rather choose the numbers yourself. Refs #163 Co-authored-by: todie <7841714+todie@users.noreply.github.com> Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VtshktvJgt8fVkBr5ZjT1J --- plugin/claude-code/scripts/post-compaction.sh | 4 +++- plugin/claude-code/scripts/session-start.sh | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugin/claude-code/scripts/post-compaction.sh b/plugin/claude-code/scripts/post-compaction.sh index a767bfc9..68bb0229 100755 --- a/plugin/claude-code/scripts/post-compaction.sh +++ b/plugin/claude-code/scripts/post-compaction.sh @@ -29,7 +29,9 @@ fi # Fetch context from previous sessions ENCODED_PROJECT=$(printf '%s' "$PROJECT" | jq -sRr @uri) -CONTEXT=$(curl -sf "${ENGRAM_URL}/context?project=${ENCODED_PROJECT}" --max-time 3 2>/dev/null | jq -r '.context // empty') +# Same bounded request as session-start.sh: compact bullets (titles kept, +# 300-char body previews dropped) and a ceiling on the pinned section. +CONTEXT=$(curl -sf "${ENGRAM_URL}/context?project=${ENCODED_PROJECT}&compact=1&pinned=20" --max-time 3 2>/dev/null | jq -r '.context // empty') # Resolve protocol verbosity mode for this slug. All slim/full branching # (including the engram-version floor check) lives in Go — see `engram diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 150a4265..532b745b 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -133,9 +133,15 @@ if [ -f "${CWD}/.engram/manifest.json" ]; then ) >/dev/null 2>&1 & fi -# Fetch memory context +# Fetch memory context. +# +# compact=1 renders observation bullets as `- [type] **title**` instead of +# appending 300 chars of body: no row disappears, only the inline preview, +# and the body is one mem_get_observation away when the agent actually wants +# it. pinned=20 puts a ceiling on the one section that never had one. +# Everything else stays at its default, so no memory silently drops out. ENCODED_PROJECT=$(printf '%s' "$PROJECT" | jq -sRr @uri) -CONTEXT=$(curl -sf "${ENGRAM_URL}/context?project=${ENCODED_PROJECT}" --max-time 3 2>/dev/null | jq -r '.context // empty') +CONTEXT=$(curl -sf "${ENGRAM_URL}/context?project=${ENCODED_PROJECT}&compact=1&pinned=20" --max-time 3 2>/dev/null | jq -r '.context // empty') # Resolve protocol verbosity mode for this slug. All slim/full branching # (including the engram-version floor check) lives in Go — see `engram From 7b3a09d6a67e0db6a2c679394dcf40d6a8dc75f6 Mon Sep 17 00:00:00 2001 From: Neuralab Marketing Date: Thu, 30 Jul 2026 11:18:03 -0300 Subject: [PATCH 4/4] =?UTF-8?q?refactor(store):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20share=20bullet=20rendering,=20cover=20error=20paths?= =?UTF-8?q?,=20document=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the Compact/full bullet rendering shared by the Pinned and Recent Observations loops into writeObservationBullet, so a future formatting change (truncate length, redaction) lands in one place instead of two copies that can drift apart. - Add TestFormatContextWithOptionsErrorBranches: four subtests that close the store before invoking, each using the option convention to skip the earlier sections so every 'return "", err' branch is reached (sessions, pinned, observations, prompts). - Document the five query params on GET /context in DOCS.md, including the 0 / >0 / <0 convention and that invalid values fall back to the default rather than returning 400. Output is unchanged: the byte-identity test still passes untouched. Refs #163 Co-authored-by: todie <7841714+todie@users.noreply.github.com> Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VtshktvJgt8fVkBr5ZjT1J --- DOCS.md | 5 +++- internal/store/store.go | 29 +++++++++++--------- internal/store/store_test.go | 51 ++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/DOCS.md b/DOCS.md index a0971d24..9571369d 100644 --- a/DOCS.md +++ b/DOCS.md @@ -171,7 +171,10 @@ Engram is local-first: local SQLite is authoritative; cloud features are optiona ### Context -- `GET /context` — Formatted context. Query: `?project=X&scope=project|personal|global` +- `GET /context` — Formatted context. Query: `?project=X&scope=project|personal|global&observations=N&prompts=N&sessions=N&pinned=N&compact=BOOL` + - `observations`/`prompts`/`sessions`/`pinned`: `0` (or omitted/invalid) uses that section's legacy default, `>0` caps it, `<0` omits the section and its `### ...` header entirely + - `compact=true` drops the inline content preview from `Pinned`/`Recent Observations` bullets, keeping just `- [type] **title**` + - Invalid or unparseable values silently fall back to their default — never a `400` ### Passive Capture diff --git a/internal/store/store.go b/internal/store/store.go index 59b2e48e..333a302b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3436,12 +3436,7 @@ func (s *Store) FormatContextWithOptions(project, scope string, opts ContextOpti if len(pinned) > 0 { b.WriteString("### Pinned\n") for _, obs := range pinned { - if opts.Compact { - fmt.Fprintf(&b, "- [%s] **%s**\n", obs.Type, obs.Title) - } else { - fmt.Fprintf(&b, "- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, 300)) - } + writeObservationBullet(&b, obs, opts.Compact) } b.WriteString("\n") } @@ -3449,12 +3444,7 @@ func (s *Store) FormatContextWithOptions(project, scope string, opts ContextOpti if len(observations) > 0 { b.WriteString("### Recent Observations\n") for _, obs := range observations { - if opts.Compact { - fmt.Fprintf(&b, "- [%s] **%s**\n", obs.Type, obs.Title) - } else { - fmt.Fprintf(&b, "- [%s] **%s**: %s\n", - obs.Type, obs.Title, truncate(obs.Content, 300)) - } + writeObservationBullet(&b, obs, opts.Compact) } b.WriteString("\n") } @@ -3462,6 +3452,21 @@ func (s *Store) FormatContextWithOptions(project, scope string, opts ContextOpti return b.String(), nil } +// writeObservationBullet renders one observation-shaped bullet shared by the +// "### Pinned" and "### Recent Observations" loops in FormatContextWithOptions, +// so both sections stay byte-for-byte in sync — a future formatting change +// (truncate length, redaction) now only has one place to land instead of two +// copies that can drift apart. When compact is true it drops the inline +// content preview; otherwise it appends up to 300 chars of obs.Content. +func writeObservationBullet(b *strings.Builder, obs Observation, compact bool) { + if compact { + fmt.Fprintf(b, "- [%s] **%s**\n", obs.Type, obs.Title) + } else { + fmt.Fprintf(b, "- [%s] **%s**: %s\n", + obs.Type, obs.Title, truncate(obs.Content, 300)) + } +} + // ─── Export / Import ───────────────────────────────────────────────────────── func (s *Store) Export() (*ExportData, error) { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6944604c..0de22ee7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8839,6 +8839,57 @@ func TestSanitizeFTS(t *testing.T) { // now a thin wrapper delegating to FormatContextWithOptions with a // zero-value ContextOptions{}. +// TestFormatContextWithOptionsErrorBranches exercises the four early +// `return "", err` branches in FormatContextWithOptions (Sessions, Pinned, +// Observations, Prompts) that TestFormatContextWithOptions (below) never +// reaches because every fetch there succeeds. Each subtest closes the +// store's DB first, so whichever fetch runs first fails — per the fetch +// order in FormatContextWithOptions (Sessions, then Pinned, then +// Observations, then Prompts), each subtest omits (opts < 0) every section +// ahead of the one under test so that section's fetch is the one that +// actually runs and fails. +func TestFormatContextWithOptionsErrorBranches(t *testing.T) { + t.Run("sessions fetch error", func(t *testing.T) { + s := newTestStore(t) + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if _, err := s.FormatContextWithOptions("engram", "project", ContextOptions{}); err == nil { + t.Fatal("expected error when the Sessions fetch fails on a closed db") + } + }) + + t.Run("pinned fetch error", func(t *testing.T) { + s := newTestStore(t) + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if _, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Sessions: -1}); err == nil { + t.Fatal("expected error when the Pinned fetch fails on a closed db") + } + }) + + t.Run("observations fetch error", func(t *testing.T) { + s := newTestStore(t) + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if _, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Sessions: -1, Pinned: -1}); err == nil { + t.Fatal("expected error when the Observations fetch fails on a closed db") + } + }) + + t.Run("prompts fetch error", func(t *testing.T) { + s := newTestStore(t) + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if _, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Sessions: -1, Pinned: -1, Observations: -1}); err == nil { + t.Fatal("expected error when the Prompts fetch fails on a closed db") + } + }) +} + // TestFormatContextWithOptions seeds enough rows per section (more than // every legacy default) so defaults, explicit caps, and omission are all // distinguishable from "everything". cfg.MaxContextResults is pinned to 3