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
25 changes: 21 additions & 4 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions internal/server/server_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ": <content>" 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 ': <body>' content preview, got:\n%s", compactData["context"])
}

statsResp, err := client.Get(ts.URL + "/stats")
if err != nil {
t.Fatalf("stats: %v", err)
Expand Down
43 changes: 40 additions & 3 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}
Expand Down
96 changes: 96 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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")
}
}
94 changes: 57 additions & 37 deletions plugin/claude-code/scripts/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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