Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Comment on lines +784 to +790

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: check whether docs mention the new /context query params.
rg -n -i 'context.*(observations|pinned|compact)=' --glob '*.md'
fd -i 'api|http' --extension md

Repository: Gentleman-Programming/engram

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | sed -n '1,200p' | rg -i '(^README|api|http|server|route|handler|test)' || true

echo
echo "== internal/server/server.go relevant lines =="
sed -n '740,830p' internal/server/server.go | nl -ba -v740

echo
echo "== route registration mentioning context =="
rg -n 'context|Context' internal/server -g '*.go'

echo
echo "== markdown files =="
git ls-files '*.md'

echo
echo "== broad /context mentions =="
rg -n -i '/context|\/context|context\s+queries|context.*query|query.*context|ContextOptions|observations|prompts|sessions|pinned|compact' -g '*.md' -g '*.go' .

Repository: Gentleman-Programming/engram

Length of output: 2551


🏁 Script executed:

#!/bin/bash
set -u

echo "== context query handling and route registration =="
rg -n 'observations|prompts|sessions|pinned|compact|ContextOptions|/context|context' internal/server internal -g '*.go' | head -200

echo
echo "== markdown files in repo =="
find . -maxdepth 4 -type f -iname '*.md' -print | sed 's#^\./##' | sort

echo
echo "== docs/search for context query parameters in markdown files only =="
find . -maxdepth 4 -type f -iname '*.md' -print | xargs -r rg -n -i 'observations|prompts|sessions|pinned|compact|context' || true

Repository: Gentleman-Programming/engram

Length of output: 50385


Document the new /context query parameters.

GET /context now accepts observations, prompts, sessions, pinned, and compact; the API spec needs the same update as the handler/test changes.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/server.go` around lines 784 - 790, Update the API
specification for the GET /context endpoint to document the observations,
prompts, sessions, pinned, and compact query parameters, matching the names and
default behavior used by the ContextOptions initialization. Keep the handler and
tests unchanged.

Source: Path instructions


context, err := s.store.FormatContextWithOptions(project, scope, opts)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
Expand Down
117 changes: 117 additions & 0 deletions internal/server/server_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
134 changes: 118 additions & 16 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `
Expand All @@ -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...)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3347,24 +3436,37 @@ 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))
writeObservationBullet(&b, obs, opts.Compact)
}
b.WriteString("\n")
}

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))
writeObservationBullet(&b, obs, opts.Compact)
}
b.WriteString("\n")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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) {
Expand Down
Loading