diff --git a/internal/server/server.go b/internal/server/server.go index abd7c39..6b7af31 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -471,10 +471,27 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { // ─── Context ───────────────────────────────────────────────────────────────── 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) + q := r.URL.Query() + project := q.Get("project") + scope := q.Get("scope") + + opts := store.ContextOptions{} + if raw := q.Get("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + opts.Limit = n + } + } + if raw := q.Get("compact"); raw != "" { + // Accepts any value strconv.ParseBool understands: "1", "t", "T", + // "TRUE", "true", "True" (and their false counterparts). Unknown + // values (e.g. "yes") silently leave Compact at its zero value so + // callers that expected full content don't get surprised. + if b, err := strconv.ParseBool(raw); err == nil { + opts.Compact = b + } + } + + 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 1d87604..95113d0 100644 --- a/internal/server/server_e2e_test.go +++ b/internal/server/server_e2e_test.go @@ -382,6 +382,26 @@ func TestCoreReadHandlersAndHelpersE2E(t *testing.T) { t.Fatalf("expected formatted context output") } + // ── limit + compact query params (added in feat/context-limit-compact). + // compact=1 drops the ": " preview from observation bullets, + // and limit=1 caps the observations section. The resulting payload must + // be strictly smaller than the default. + compactResp, err := client.Get(ts.URL + "/context?project=engram&scope=project&limit=1&compact=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(contextData["context"]) { + t.Fatalf("compact context (%d) should be smaller than default (%d)", + len(compactData["context"]), len(contextData["context"])) + } + if strings.Contains(compactData["context"], "- [") && strings.Contains(compactData["context"], "**: ") { + t.Fatalf("compact context should not include ': ' content preview, got:\n%s", compactData["context"]) + } + statsResp, err := client.Get(ts.URL + "/stats") if err != nil { t.Fatalf("stats: %v", err) diff --git a/internal/store/store.go b/internal/store/store.go index 670e247..7ca77a3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1610,13 +1610,46 @@ func (s *Store) Stats() (*Stats, error) { // ─── Context Formatting ───────────────────────────────────────────────────── +// ContextOptions tunes the output of FormatContextWithOptions. +// +// A zero-value ContextOptions (Limit==0, Compact==false) reproduces the +// legacy FormatContext behaviour exactly, so it is safe to pass through +// from handlers that do not care about the new knobs. +type ContextOptions struct { + // Limit caps the number of observations rendered into the context + // string. Zero means "use the store-wide MaxContextResults default". + // Sessions and prompts are not affected — their counts are fixed at + // 5 and 10 respectively, matching the legacy behaviour. + Limit int + + // Compact drops the inline content preview on each observation bullet, + // rendering `- [type] **title**` instead of `- [type] **title**: <300 + // chars of body>`. On a busy project this cuts the observation section + // by ~80%. Sessions and prompts are not affected. + Compact bool +} + +// FormatContext is a thin wrapper around FormatContextWithOptions that uses +// default options, preserving the pre-ContextOptions call signature so +// existing callers and tests keep working unchanged. func (s *Store) FormatContext(project, scope string) (string, error) { + return s.FormatContextWithOptions(project, scope, ContextOptions{}) +} + +// FormatContextWithOptions renders the "## Memory from Previous Sessions" +// markdown block for the given project/scope, honoring the supplied +// ContextOptions for observation limit and compact rendering. +func (s *Store) FormatContextWithOptions(project, scope string, opts ContextOptions) (string, error) { sessions, err := s.RecentSessions(project, 5) if err != nil { return "", err } - observations, err := s.RecentObservations(project, scope, s.cfg.MaxContextResults) + obsLimit := opts.Limit + if obsLimit <= 0 { + obsLimit = s.cfg.MaxContextResults + } + observations, err := s.RecentObservations(project, scope, obsLimit) if err != nil { return "", err } @@ -1657,8 +1690,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 ea01f9e..334af39 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -4439,3 +4440,98 @@ func TestCountObservationsForProject(t *testing.T) { t.Errorf("expected 0 for beta, got %d", count) } } + +func TestFormatContextWithOptions(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("ctx-sess", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + // Seed 6 observations with multi-line bodies so Compact has something + // meaningful to drop. Titles are unique per observation so we can assert + // exactly which ones survive the Limit filter. + for i := 0; i < 6; i++ { + _, err := s.AddObservation(AddObservationParams{ + SessionID: "ctx-sess", + Type: "decision", + Title: fmt.Sprintf("obs-%d", i), + Content: fmt.Sprintf("## Goal\nLine one for obs %d\n\n## Details\nThis is a long body that should be dropped in Compact mode and capped in default mode. Lorem ipsum dolor sit amet consectetur.", i), + Project: "engram", + Scope: "project", + }) + if err != nil { + t.Fatalf("add obs %d: %v", i, err) + } + } + + // ── Default options ─ backward compat: should behave exactly like + // FormatContext and include the full (truncated) content preview. + defaultCtx, err := s.FormatContextWithOptions("engram", "project", ContextOptions{}) + if err != nil { + t.Fatalf("format context default: %v", err) + } + legacyCtx, err := s.FormatContext("engram", "project") + if err != nil { + t.Fatalf("format context legacy: %v", err) + } + if defaultCtx != legacyCtx { + t.Fatalf("default ContextOptions should match legacy FormatContext output.\ndefault:\n%s\nlegacy:\n%s", defaultCtx, legacyCtx) + } + if !strings.Contains(defaultCtx, "Lorem ipsum") { + t.Fatalf("default mode should include content preview, got:\n%s", defaultCtx) + } + + // ── Compact ─ content preview dropped. + compactCtx, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Compact: true}) + if err != nil { + t.Fatalf("format context compact: %v", err) + } + if strings.Contains(compactCtx, "Lorem ipsum") { + t.Fatalf("compact mode should drop content preview, got:\n%s", compactCtx) + } + if !strings.Contains(compactCtx, "**obs-5**") { + t.Fatalf("compact mode should still include observation titles, got:\n%s", compactCtx) + } + // Compact bullet format: `- [type] **title**` (no trailing `: ...`). + if !strings.Contains(compactCtx, "- [decision] **obs-5**\n") { + t.Fatalf("compact bullet format mismatch, got:\n%s", compactCtx) + } + // Compact output must be strictly smaller than default output on this + // workload (6 multi-line observations). + if len(compactCtx) >= len(defaultCtx) { + t.Fatalf("compact (%d) should be smaller than default (%d)", len(compactCtx), len(defaultCtx)) + } + + // ── Limit ─ caps observation count. Ask for 2; expect exactly 2 of + // the 6 inserted observations in the output. + limitedCtx, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Limit: 2}) + if err != nil { + t.Fatalf("format context limited: %v", err) + } + gotObs := strings.Count(limitedCtx, "- [decision] **obs-") + if gotObs != 2 { + t.Fatalf("expected 2 observations under Limit=2, got %d\n%s", gotObs, limitedCtx) + } + + // ── Limit + Compact combined ─ both knobs apply independently. + combinedCtx, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Limit: 3, Compact: true}) + if err != nil { + t.Fatalf("format context combined: %v", err) + } + if strings.Count(combinedCtx, "- [decision] **obs-") != 3 { + t.Fatalf("expected 3 observations under Limit=3+Compact, got:\n%s", combinedCtx) + } + if strings.Contains(combinedCtx, "Lorem ipsum") { + t.Fatalf("Limit+Compact should still drop content, got:\n%s", combinedCtx) + } + + // ── Limit <= 0 falls back to the store default. + zeroLimitCtx, err := s.FormatContextWithOptions("engram", "project", ContextOptions{Limit: 0}) + if err != nil { + t.Fatalf("format context zero limit: %v", err) + } + if zeroLimitCtx != defaultCtx { + t.Fatalf("Limit=0 should equal default output") + } +} diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 8365e10..1cedc8e 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -4,11 +4,22 @@ # 1. Ensures the engram server is running # 2. Creates a session in engram # 3. Auto-imports git-synced chunks if .engram/manifest.json exists -# 4. Injects Memory Protocol instructions + memory context +# 4. Injects a minimal tool-availability pointer + compacted memory context +# +# Memory protocol (when/what to save, search, close) lives in the +# `engram:memory` skill shipped with this plugin and is loaded on demand. +# Re-injecting the full protocol on every SessionStart wastes ~1.8 KB of +# context window per session, so this script only emits a short pointer. ENGRAM_PORT="${ENGRAM_PORT:-7437}" ENGRAM_URL="http://127.0.0.1:${ENGRAM_PORT}" +# Tunables (override via env) +# ENGRAM_CONTEXT_LIMIT — max observations to inject (default 8) +# ENGRAM_CONTEXT_MAXLEN — max chars per observation line (default 140) +CTX_LIMIT="${ENGRAM_CONTEXT_LIMIT:-8}" +CTX_MAXLEN="${ENGRAM_CONTEXT_MAXLEN:-140}" + # Load shared helpers SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/_helpers.sh" @@ -51,50 +62,59 @@ if [ -f "${CWD}/.engram/manifest.json" ]; then engram sync --import 2>/dev/null fi -# Fetch memory context +# Fetch memory context with server-side limit + compact rendering. +# +# The /context endpoint honors `limit` (max observations) and `compact=1` +# (drop inline content preview) as of the feat/context-limit-compact change. +# Older engram binaries silently ignore the query params and return the +# full context, which this script then post-processes with awk as a +# belt-and-suspenders fallback — so the hook works against both old and +# new servers. 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}&limit=${CTX_LIMIT}&compact=1" --max-time 3 2>/dev/null | jq -r '.context // empty') + +# Fallback post-processing for older servers that don't honor compact=1. +# On a new server the observation section is already single-line-per-bullet +# with no content preview, so this awk pass is a near no-op; on an old +# server it concatenates each bullet's continuation lines, collapses +# whitespace, caps per-bullet length at $CTX_MAXLEN, and enforces the same +# $CTX_LIMIT as a safety net. +if [ -n "$CONTEXT" ]; then + CONTEXT=$(printf '%s\n' "$CONTEXT" | awk -v lim="$CTX_LIMIT" -v max="$CTX_MAXLEN" ' + function flush() { + if (buf == "") return + if (kept < lim) { + gsub(/[[:space:]]+/, " ", buf) + if (length(buf) > max) buf = substr(buf, 1, max - 1) "…" + print buf + kept++ + } + buf = "" + } + /^### Recent Observations/ { flush(); in_obs = 1; print; next } + /^### / { flush(); in_obs = 0; print; next } + in_obs && /^- \[/ { flush(); buf = $0; next } + in_obs { if (buf != "") buf = buf " " $0; next } + { print } + END { flush() } + ') +fi -# Inject Memory Protocol + context — stdout goes to Claude as additionalContext +# Inject minimal protocol pointer + compacted context as additionalContext. cat <<'PROTOCOL' -## Engram Persistent Memory — ACTIVE PROTOCOL - -You have engram memory tools. This protocol is MANDATORY and ALWAYS ACTIVE. - -### CORE TOOLS — always available, no ToolSearch needed -mem_save, mem_search, mem_context, mem_session_summary, mem_get_observation, mem_save_prompt - -Use ToolSearch for other tools: mem_update, mem_suggest_topic_key, mem_session_start, mem_session_end, mem_stats, mem_delete, mem_timeline, mem_capture_passive - -### PROACTIVE SAVE — do NOT wait for user to ask -Call `mem_save` IMMEDIATELY after ANY of these: -- Decision made (architecture, convention, workflow, tool choice) -- Bug fixed (include root cause) -- Convention or workflow documented/updated -- Notion/Jira/GitHub artifact created or updated with significant content -- Non-obvious discovery, gotcha, or edge case found -- Pattern established (naming, structure, approach) -- User preference or constraint learned -- Feature implemented with non-obvious approach -- User confirms your recommendation ("dale", "go with that", "sounds good", "sí, esa") -- User rejects an approach or expresses a preference ("no, better X", "I prefer X", "siempre hacé X") -- Discussion concludes with a clear direction chosen - -**Self-check after EVERY task**: "Did I or the user just make a decision, confirm a recommendation, express a preference, fix a bug, learn something, or establish a convention? If yes → mem_save NOW." +## Engram Memory — active -### SEARCH MEMORY when: -- User asks to recall anything ("remember", "what did we do", "acordate", "qué hicimos") -- Starting work on something that might have been done before -- User mentions a topic you have no context on -- User's FIRST message references the project, a feature, or a problem — call `mem_search` with keywords from their message to check for prior work before responding +Core tools (always available): mem_save, mem_search, mem_context, +mem_session_summary, mem_get_observation, mem_suggest_topic_key, mem_update, +mem_session_start, mem_session_end, mem_save_prompt. +Admin tools via ToolSearch: mem_stats, mem_delete, mem_timeline, mem_capture_passive. -### SESSION CLOSE — before saying "done"/"listo": -Call `mem_session_summary` with: Goal, Discoveries, Accomplished, Next Steps, Relevant Files. +Full protocol (when/what to save, search rules, session close) lives in the +`engram:memory` skill — load it on demand when you need the rules. PROTOCOL -# Inject memory context if available if [ -n "$CONTEXT" ]; then - printf "\n%s\n" "$CONTEXT" + printf '\n%s\n' "$CONTEXT" fi exit 0