feat(context): add limit & compact query params to GET /context (byte-compatible)#162
Open
todie wants to merge 3 commits intoGentleman-Programming:mainfrom
Open
feat(context): add limit & compact query params to GET /context (byte-compatible)#162todie wants to merge 3 commits intoGentleman-Programming:mainfrom
todie wants to merge 3 commits intoGentleman-Programming:mainfrom
Conversation
) The SessionStart hook was injecting ~10 KB of additionalContext into every Claude Code session: - ~1.8 KB hardcoded "ACTIVE PROTOCOL" heredoc that duplicates the rules already shipped in skills/memory/SKILL.md (which loads on demand) - ~8 KB of /context payload, because the server inlines up to 300 chars of raw (often multi-line markdown) content per observation bullet and returns up to MaxContextResults (default 20) On a busy project this meant every new session burned ~2.5k tokens on redundant protocol reminders and verbose observation previews before the user had typed a single word. Changes to plugin/claude-code/scripts/session-start.sh: 1. Replace the 35-line PROTOCOL heredoc with a short pointer that lists the available tools and directs the agent to the engram:memory skill for the full protocol. The skill is already part of this plugin, so the rules are one ToolSearch away when they are actually needed. 2. Post-process the /context response with awk to (a) concatenate each observation's multi-line content onto a single line, (b) collapse whitespace, (c) cap per-bullet length at ENGRAM_CONTEXT_MAXLEN chars (default 140), and (d) keep at most ENGRAM_CONTEXT_LIMIT bullets (default 8). Both tunables are env-overridable so users can dial the verbosity back up if they want. No server or Go changes — fully backward compatible. The raw /context endpoint behaviour is unchanged; only the hook's rendering of it is trimmed. Measured on a ctodie project with 200+ observations: before: 7961 B context + ~1800 B protocol ≈ 9.8 KB per session start after: 1487 B context + ~450 B protocol ≈ 1.9 KB per session start savings: ~8 KB (~80%) of additionalContext per session Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Stacked on top of #1 (perf: shrink SessionStart hook injection). The /context endpoint always rendered every recent observation with up to 300 chars of raw content inlined per bullet, returning ~8 KB on any busy project. PR #1 worked around this client-side with awk; this commit moves the knobs into the server so the rendering choice lives next to the data. New query parameters -------------------- limit=N Cap the observations section at N bullets. Zero or absent falls back to the store-wide MaxContextResults default. Sessions and prompts are not affected (still 5 and 10). compact=1 Render observation bullets as `- [type] **title**` only, dropping the `: <300 chars of body>` preview. Parsed with strconv.ParseBool so `1`, `true`, `yes`, etc. all work. Backward compatibility ---------------------- FormatContext(project, scope) stays as a thin wrapper over the new FormatContextWithOptions(project, scope, opts) method with a zero-value ContextOptions, so all ~15 existing callers and test fixtures are untouched. A GET /context request with no new params produces byte-for- byte identical output to the pre-change binary (verified empirically, see benchmark below). Hook update ----------- plugin/claude-code/scripts/session-start.sh now passes `?limit=${ENGRAM_CONTEXT_LIMIT}&compact=1` to the server. The awk post-processor from #1 stays in place as a belt-and-suspenders fallback for users whose binary is older than their plugin — on a new server it's a near no-op; on an old server it still compacts things client-side. Tests ----- - internal/store: new TestFormatContextWithOptions covers default equivalence with FormatContext, Compact dropping previews, Limit capping observations, Limit+Compact composition, and Limit<=0 falling back to the default. - internal/server: e2e test now hits /context with and without &limit=1&compact=1 and asserts the compact payload is strictly smaller and contains no `: <body>` segments. Full suite: 748 passed in 10 packages. Benchmark (ctodie project, 200+ observations, loopback) ------------------------------------------------------- old binary, /context 7949 B ~1.0 ms new binary, /context (default params) 7949 B ~0.9 ms ← identical bytes new binary, /context?limit=8&compact=1 728 B ~0.8 ms ← −91% End-to-end SessionStart hook injection -------------------------------------- original (before #1): ~9800 B after #1 (shell awk only): ~1900 B −81% after #1 + #2 (server-side): ~1151 B −88% PR #1 alone already solved the token cost for users who can't upgrade their binary; this PR makes the fast path the default and keeps the shell fallback for mixed-version deployments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Go's strconv.ParseBool only accepts 1/0/t/f/T/F/true/false/True/False/ TRUE/FALSE. The original comment claimed 'yes' was also supported; it isn't — compact=yes silently falls back to Compact=false. Verified against the live binary: /context?limit=8&compact=yes returned 8 full (non-compact) observations. Drive-by doc fix; no behaviour change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This was referenced Apr 7, 2026
Alan-TheGentleman
approved these changes
Apr 12, 2026
Collaborator
Alan-TheGentleman
left a comment
There was a problem hiding this comment.
Review — APPROVED
Excelente PR. Backward compatibility verificada al 100%, bien testeada, y la implementación es idiomática.
Backward Compatibility ✓
FormatContext(project, scope)sigue existiendo como wrapper deFormatContextWithOptionscon zero-value options- MCP clients siguen llamando
FormatContext()directamente — output byte-por-byte idéntico GET /contextsin query params → flujo idéntico al handler anterior- El test de
defaultCtx == legacyCtxcomo string equality es un guardrail excelente
Compact mode ✓
- Elimina solo el preview de contenido, mantiene type + título + sesiones + prompts
- Suficiente para que un agente sepa QUÉ existe y haga
mem_get_observationon-demand
Edge cases ✓
limit=0,limit=-1,limit=abc→ todos caen al default correctamente- Double safety net en
RecentObservations— redundante pero seguro limit+compactcombinados → testeado
Observaciones menores (non-blocking)
- Test e2e de compact: la assertion busca
**:como substring global — podría matchear un título de observation. Mejor verificar línea por línea. compact=yescae silenciosamente a false:strconv.ParseBoolno acepta "yes"/"on". El fallback es safe (retorna full context) pero puede confundir.- Squash commits antes de merge — el commit 1 es de PR #161.
Fantástico laburo. El patrón de options struct + wrapper es consistente con SearchOptions del mismo codebase. Se puede mergear con confianza.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #163 (together with #161).
Stacking note
This PR is stacked on top of #161 (perf: shrink SessionStart hook injection). Review #161 first. Because GitHub doesn't support cross-repo stacked PRs with a fork base, this PR's diff against
maincurrently includes #161's changes. Once #161 lands, I'll rebase and force-push-with-lease, and the diff will shrink to just this PR's additions (5 files, +192/−17).If you'd prefer, I can open this as a single combined PR instead — just let me know.
Motivation
#161 solved the SessionStart token cost client-side, in the hook, using
awk. That's the right shape for users who can't easily rebuild the engram binary, but it has drawbacks as a long-term solution:###headers that could confuse section tracking, etc. Doing it in Go is about 20 lines and covers all of them correctly.This PR moves the knobs into the server via two new query parameters on
GET /context, while preserving byte-for-byte backward compatibility for existing callers.Design: byte-compatible interface via zero-value options struct
The core requirement: don't break any existing caller.
FormatContextis called from:internal/server/server.go(HTTP handler) — 1 siteinternal/mcp/mcp.go(MCP context tool) — 1 sitecmd/engram/main.go(CLIcontextcommand) + test shimstoreFormatContext— 2 sitesinternal/store/store_test.go— 8 direct test assertionscmd/engram/main_extra_test.go— 2 shim overridesChanging the signature of
FormatContext(project, scope string)would cascade to ~15 call sites, many of them in tests that pin exact output strings. That's too much churn for a feature addition.The pattern I chose — used elsewhere in this codebase (
SearchOptions,AddObservationParams) — is the options struct + wrapper idiom:Byte-compatibility proof: a zero-value
ContextOptions{}hits the same code paths as the oldFormatContextbody. TheLimit <= 0branch falls back tos.cfg.MaxContextResults(exactly what the old code used). The!opts.Compactbranch renders- [%s] **%s**: %s\nwithtruncate(obs.Content, 300)(exactly the old format string). No conditionals flip, no new allocations in the default path.Empirical verification: I built both the old binary (
main) and this branch, pointed them at the same 200+ observation database, and compared the responses:The default-params path is a byte-identical drop-in replacement for the old behavior.
Query parameters
limit0, negative, missing, or unparseable → falls back toMaxContextResults(default 20). Sessions (5) and prompts (10) are never affected.compactstrconv.ParseBool:1,0,t,f,T,F,true,false,True,False,TRUE,FALSE- [type] **title**only, dropping the: <300 chars of body>preview. Unknown values silently fall back tofalse(legacy behavior).Caveat on
compact=: I originally documented "yes" as accepted because I assumedParseBoolfollowed common-sense rules. It doesn't — only the 12 values above. The second commit in this PR fixes the misleading inline comment. I considered acceptingyes/novia a custom parser but decided consistency with the Go stdlib was more valuable than convenience, and silent fallback tofalseis safer than erroring out on unknown values (bad clients get the old behavior, not a 400).Hook integration (matched-version fast path + mixed-version fallback)
plugin/claude-code/scripts/session-start.shis updated to pass?limit=${ENGRAM_CONTEXT_LIMIT}&compact=1. On a matched-version deployment (new plugin + new binary), the server does the compaction and the awk pass from #161 becomes a near no-op. On a mixed-version deployment (new plugin + old binary), the old binary silently ignores the unknown query params, returns the full response, and the awk fallback from #161 compacts it client-side. Both cases produce a correctly compacted output.This is the main reason I kept the awk pass in the hook rather than removing it in this PR.
Measurement methodology
engram.dbwith 200+ observations across several months (ctodieproject), all inpersonalscope. Mix ofsession_summary(multi-line markdown),pattern,discovery,decision,manual,bugtypes.main, new = this branch), ran each in turn on port 7438 to avoid stepping on the live daemon's port. Hit/context3 times per variant withcurl -w \"time_total=%{time_total}s size=%{size_download}\\n\"to smooth out cold-start jitter.main)/context?project=ctodie/context?project=ctodie(default params)/context?project=ctodie&limit=8&compact=1End-to-end SessionStart hook injection, real project:
Risk analysis
What could break?
FormatContext. Mitigated byTestFormatContextWithOptionsassertingdefaultCtx == legacyCtxas exact string equality. Any future refactor that touchesFormatContextWithOptionswithout updating this test will flag a drift immediately.strconv.Atoi/strconv.ParseBoolbefore any store interaction. Unparseable values silently fall back to defaults, never reach the store, and never affect the SQL query (the store-levellimitpassed toRecentObservationsis either a validated positive int or the config default).go test ./...→ 748 passed → 749 passed (one new test) → 750 passed (one more assertion in the e2e suite). All existing tests untouched.internal/mcp/mcp.gowhich still callsFormatContext(project, scope)— i.e. the backward-compat wrapper. Their output is unchanged byte-for-byte.What's the rollback?
git revert <merge-commit>. No database migration, no config file format change, no on-disk state change. The newContextOptionsstruct andFormatContextWithOptionsmethod disappear with the revert; nothing outside the store package depends on them.Tests
internal/store/store_test.go::TestFormatContextWithOptions. Covers:ContextOptions{}→ byte-equal toFormatContext(backward compat)Compact: true→ drops content preview, keeps titlesLimit: 2→ exactly 2 observation bullets (usingstrings.Count)Limit: 3, Compact: true→ both knobs compose correctlyLimit: 0→ falls back to default (byte-equal to legacy)internal/server/server_e2e_test.go's/contexttest now also hits?limit=1&compact=1and asserts the compact payload is strictly smaller than the default and contains no: <body>segments.go test ./...→ 748 passed in 10 packages (was 747 before the new test; a second e2e assertion brings it to 749 on this branch).Files changed (this PR's additions only, excluding #161)
Commits
feat(context): add limit + compact query params to GET /context— the main changedocs(context): fix compact= param comment — ParseBool rejects 'yes'— drive-by doc fix after I benchmarked the live binary and caught the inaccuracyHappy to squash if you prefer a single commit.
🤖 Generated with Claude Code