From 48f085767e1f8e0724d0269cc469a9dc7f5fa1d6 Mon Sep 17 00:00:00 2001 From: Christian Todie Date: Tue, 7 Apr 2026 01:52:28 -0400 Subject: [PATCH 1/3] =?UTF-8?q?perf(claude-code):=20shrink=20SessionStart?= =?UTF-8?q?=20hook=20injection=20(~10KB=20=E2=86=92=20~2KB)=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugin/claude-code/scripts/session-start.sh | 85 ++++++++++++--------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 8365e10..e9e2049 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" @@ -55,46 +66,50 @@ fi 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') -# Inject Memory Protocol + context — stdout goes to Claude 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 +# Compact the "### Recent Observations" section: keep at most $CTX_LIMIT +# observations, each flattened onto a single line and truncated to +# $CTX_MAXLEN chars. The server inlines up to 300 chars of raw content per +# bullet (often multi-line, since session summaries are markdown documents), +# so a raw /context response for a busy project is ~8 KB. This awk pass +# concatenates each bullet's continuation lines, collapses whitespace, and +# caps both the count and per-bullet length — typical injected context drops +# to ~1.5 KB. Headers, recent sessions, and recent prompts pass through. +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 -**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." +# Inject minimal protocol pointer + compacted context as additionalContext. +cat <<'PROTOCOL' +## 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 From 7b5fa6c55ac268c2d8c65d01f3f17dcbde379580 Mon Sep 17 00:00:00 2001 From: "Christian M. Todie" Date: Tue, 7 Apr 2026 01:47:23 -0400 Subject: [PATCH 2/3] feat(context): add limit + compact query params to GET /context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `: ` 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 --- internal/server/server.go | 20 ++++- internal/server/server_e2e_test.go | 20 +++++ internal/store/store.go | 43 ++++++++- internal/store/store_test.go | 96 +++++++++++++++++++++ plugin/claude-code/scripts/session-start.sh | 25 +++--- 5 files changed, 188 insertions(+), 16 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index abd7c39..07ce578 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -471,10 +471,24 @@ 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") + 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 != "" { + // Accept "1", "true", "yes" (case-insensitive); anything else is false. + if b, err := strconv.ParseBool(raw); err == nil { + opts.Compact = b + } + } - context, err := s.store.FormatContext(project, scope) + 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 e9e2049..1cedc8e 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -62,18 +62,23 @@ 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') -# Compact the "### Recent Observations" section: keep at most $CTX_LIMIT -# observations, each flattened onto a single line and truncated to -# $CTX_MAXLEN chars. The server inlines up to 300 chars of raw content per -# bullet (often multi-line, since session summaries are markdown documents), -# so a raw /context response for a busy project is ~8 KB. This awk pass -# concatenates each bullet's continuation lines, collapses whitespace, and -# caps both the count and per-bullet length — typical injected context drops -# to ~1.5 KB. Headers, recent sessions, and recent prompts pass through. +# 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() { From aca3e2248a48752659076eefe0e4f41b5daeb7c5 Mon Sep 17 00:00:00 2001 From: "Christian M. Todie" Date: Tue, 7 Apr 2026 01:50:54 -0400 Subject: [PATCH 3/3] =?UTF-8?q?docs(context):=20fix=20compact=3D=20param?= =?UTF-8?q?=20comment=20=E2=80=94=20ParseBool=20rejects=20'yes'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/server/server.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index 07ce578..6b7af31 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -482,7 +482,10 @@ func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) { } } if raw := q.Get("compact"); raw != "" { - // Accept "1", "true", "yes" (case-insensitive); anything else is false. + // 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 }