eagle-mem 4.14.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to the **Eagle Mem** project are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## v4.15.0 PreCompact Synchronous Capture
8
+
9
+ A new lifecycle hook that captures a rich session summary **before** Claude Code compacts the context window — closing the auto-compaction gap.
10
+
11
+ - **Why.** Eagle Mem already survived compaction via per-turn `Stop` capture + `SessionStart(source=compact)` rehydration. But the *rich* (LLM-enriched) summary is produced by a `nohup` background job (`scripts/enrich-summary.sh`) the `Stop` hook fires, so on auto-compaction it can land *after* the window collapses rather than before. `PreCompact` was listed as relied-upon in the compatibility doc but had no implementation.
12
+ - **What.** New `hooks/pre-compact.sh` runs on the `PreCompact` event (both `manual` and `auto` matchers, since PreCompact's matcher *is* the trigger). It resolves the project, **skips** when an agent-authored `eagle-mem session save` already exists (that capture is richer and already survives), extracts the recent assistant transcript text, and runs `scripts/enrich-summary.sh` **synchronously** so the enriched summary is persisted before compaction. PreCompact fires rarely, so it can afford the LLM call the fast `Stop` path defers for speed.
13
+ - **Safety.** It is side-effect-only per the hooks contract (it *can* block via exit 2 but **never does** — it always `exit 0`), never overwrites an agent row (`enrich-summary.sh` is fill-only over `capture_source = agent`), redacts before sending transcript text to a provider, and degrades to a no-op when no provider is configured (the `Stop` heuristic summary already covers that case). No change to `Stop`, `enrich-summary.sh`, or any stdin/stdout/exit contract.
14
+ - **Registration.** `lib/hooks.sh:eagle_register_claude_hooks` (the single source of truth `install.sh` and `update.sh` both call) cleans then registers `PreCompact` for `manual` and `auto`. `eagle-mem update` adds it to existing installs automatically.
15
+
16
+ New coverage: `tests/test_precompact_capture.sh` (synchronous enrichment via a stubbed provider, agent-summary no-clobber, no-provider graceful path, never-blocks, both matchers) and `tests/fixtures/agent-hooks/claude-pre-compact.json`. Compatibility evidence: `docs/agent-compatibility/claude-code.md`.
17
+
18
+ ---
19
+
20
+ ## v4.14.1 busy_timeout Echo Fix
21
+
22
+ A latent data-corruption and statusline-display bug, introduced when `busy_timeout` protection was added to three hot SQLite paths (the 2026-06-10 data-integrity hardening). No new feature; pure correctness.
23
+
24
+ - **Root cause.** Setting the busy timeout with an inline value-form `PRAGMA busy_timeout=10000;` run in the *same* `sqlite3` invocation as a `SELECT` makes SQLite echo the timeout value (`10000`) as the **first output row**. Any caller that reads the first line then mis-reads `10000` as query data.
25
+ - **Impact (all three fixed).**
26
+ - `scripts/statusline-em.sh`: the HUD parsed the echoed `10000` as the session count, rendering `Sessions: 10000`, `Memories: 0`, `Last: never`.
27
+ - `lib/common.sh` `eagle_get_session_project_light`: returned `10000` as the project, **mis-filing sessions under a phantom `10000` project key** (the data-corruption vector).
28
+ - `lib/common.sh` `eagle_project_has_table_row`: the echoed value failed the `= "1"` test, so it **always reported "no row"**, breaking ancestor-project repair.
29
+ - **Fix.** All three set the timeout via the silent `-cmd ".timeout 10000"` dot-command, which emits no row. No SQL behavior, hook contract, statusline schema, or output format changes — only the timeout-set mechanism. `db/migrate.sh` (uses `tail -n1`) and the `.output`-bracketed setups in `lib/db-core.sh` were already echo-safe and are unchanged.
30
+ - **No data migration needed for most installs.** The corruption only accrues while running unpatched code, and only affects session→project tags. The release ships the durable source fix so `eagle-mem update` can no longer reintroduce the bug.
31
+
32
+ New coverage: `tests/test_busy_timeout_echo.sh` (reproduces the failure — buggy form returns `10000`/always-false — plus a structural guard against re-introducing the inline value-PRAGMA-before-SELECT shape). Compatibility evidence: `docs/agent-compatibility/claude-code.md`.
33
+
34
+ ---
35
+
7
36
  ## v4.14.0 Governance Parity & Curator Provenance
8
37
 
9
38
  Two trust-surface features that close the remaining governance gaps from the v4.13.0 review.
@@ -1,6 +1,6 @@
1
1
  # Claude Code Compatibility
2
2
 
3
- Last verified: 2026-06-10
3
+ Last verified: 2026-06-13
4
4
 
5
5
  ## Official Sources
6
6
 
@@ -28,6 +28,7 @@ Last verified: 2026-06-10
28
28
  - The installer adds `permissions.allow: ["Bash(eagle-mem session save:*)"]` to Claude settings so the capture runs without a permission prompt. The instruction must use that exact command prefix (no leading path, no `cd &&`) for the permission to match.
29
29
  - Agent-authored captures are authoritative (`capture_source = agent`). The `Stop` hook still parses any `<eagle-summary>` block for backward compatibility, but when an agent row already exists its heuristics only fill empty fields and background enrichment is skipped — neither can clobber agent data.
30
30
  - `UserPromptSubmit` context-pressure nudges (≥20 / ≥30 turns) also point at `eagle-mem session save`, not the raw block.
31
+ - `PreCompact` (matchers `manual` and `auto`) runs `hooks/pre-compact.sh`, which captures a rich summary **synchronously before** the window collapses. Routine `Stop` defers LLM enrichment to a `nohup` background job (`scripts/enrich-summary.sh`) for speed; PreCompact fires rarely, so it runs that same enricher inline, guaranteeing the rich summary is persisted before compaction rather than racing it. It never blocks compaction (always exits 0) and never overwrites an agent-authored `eagle-mem session save` (it skips when `capture_source = agent`). The compacted window is then rehydrated by `SessionStart(source=compact)`.
31
32
  - On install/update the managed `## Eagle Mem — Persistent Memory` section in `~/.claude/CLAUDE.md` is rewritten to this CLI-first doctrine whenever it predates it. Detection keys on the absence of the current section's `session save --session-id` sentinel (v4.12.1 fixed a `grep -F` escaping bug that left the section — and therefore the clean-capture behavior — un-updated). Covered by `tests/test_claude_md_capture_doctrine.sh`.
32
33
 
33
34
  ## Eagle Mem Files Depending On This
@@ -41,6 +42,7 @@ Last verified: 2026-06-10
41
42
  - `hooks/user-prompt-submit.sh`
42
43
  - `hooks/pre-tool-use.sh`
43
44
  - `hooks/post-tool-use.sh`
45
+ - `hooks/pre-compact.sh`
44
46
  - `hooks/stop.sh`
45
47
  - `hooks/session-end.sh`
46
48
 
@@ -48,9 +50,11 @@ Last verified: 2026-06-10
48
50
 
49
51
  - `tests/fixtures/agent-hooks/claude-user-prompt-submit.json`
50
52
  - `tests/fixtures/agent-hooks/claude-statusline.json`
53
+ - `tests/fixtures/agent-hooks/claude-pre-compact.json`
51
54
  - `tests/test_agent_compatibility_docs_gate.sh`
52
55
  - `tests/test_recall_observability.sh`
53
56
  - `tests/test_compaction_survival_matrix.sh`
57
+ - `tests/test_precompact_capture.sh`
54
58
  - `tests/test_trust_surfaces.sh`
55
59
 
56
60
  ## Reverification Notes
@@ -84,3 +88,21 @@ Phase 4 token-economy hardening added a generous global size ceiling on the reca
84
88
 
85
89
  Covered by `tests/test_context_budget.sh`.
86
90
 
91
+ ### Evidence: busy_timeout echo fix (2026-06-13)
92
+
93
+ Corrects a regression introduced by the 2026-06-10 data-integrity note above. Setting the SQLite busy timeout with an inline value-form `PRAGMA busy_timeout=10000;` run in the *same* `sqlite3` invocation as a `SELECT` echoes the timeout value (`10000`) as the first output row. Any caller that reads the first row then misreads `10000` as data. The earlier note's claim that "statusline output rendering [is] unchanged" was therefore wrong — the statusline showed `Sessions: 10000`, `Memories: 0`. The fix sets the timeout via the silent `-cmd ".timeout 10000"` dot-command (no echoed row); no Claude Code contract changes (hook events, statusline stdin schema, and stdout/exit semantics are all unchanged). Specifically:
94
+
95
+ - `scripts/statusline-em.sh`: the stats query drops the inline `PRAGMA busy_timeout=10000;` prefix and passes `-cmd ".timeout 10000"` to `sqlite3` instead, so `IFS='|' read` parses the real `sessions|memories|last` row rather than the echoed `10000`.
96
+ - `lib/common.sh`: `eagle_get_session_project_light` (was returning `10000` as the project, mis-filing sessions under a phantom `10000` project key) and `eagle_project_has_table_row` (whose `= "1"` test always failed on the echoed value, so ancestor-project repair always saw "no row") use the same `-cmd ".timeout"` form.
97
+
98
+ Covered by `tests/test_busy_timeout_echo.sh` (reproduces the failure: the buggy form returns `10000`/always-false; also a structural guard against re-introducing the inline value-PRAGMA-before-SELECT shape).
99
+
100
+ ### Evidence: PreCompact synchronous capture (2026-06-13)
101
+
102
+ `PreCompact` was previously listed under "Behavior Relied On" but had no implementation — Eagle Mem relied solely on per-turn `Stop` capture plus `SessionStart(source=compact)` rehydration. That covers compaction survival, but the rich (LLM-enriched) summary is produced by a `nohup` background job (`scripts/enrich-summary.sh`) the `Stop` hook fires, so for auto-compaction it can land *after* the window collapses rather than before. Verified against the hooks reference (https://code.claude.com/docs/en/hooks): `PreCompact` receives `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `trigger` (`manual`|`auto`); its matcher is the trigger; it can block (exit 2) but cannot inject `additionalContext` into the compacted window. So the new hook is side-effect-only and uses the boundary purely to persist a rich summary first.
103
+
104
+ - `hooks/pre-compact.sh` (new): on `PreCompact` it resolves the project, skips when an agent-authored summary already exists (`capture_source = agent`), extracts the recent assistant transcript text, and runs `scripts/enrich-summary.sh` **synchronously** so the enriched summary is written before compaction. It always `exit 0` (never blocks compaction) and emits a `compaction_capture` event. No change to `Stop`, `enrich-summary.sh`, or any stdin/stdout/exit contract.
105
+ - `lib/hooks.sh`: `eagle_register_claude_hooks` cleans then registers `PreCompact` for both the `manual` and `auto` matchers (the trigger-as-matcher contract), so auto-compaction is covered. `install.sh` and `update.sh` both call this single registration site.
106
+
107
+ Covered by `tests/test_precompact_capture.sh` (synchronous enrichment via a stubbed provider, agent-summary no-clobber, no-provider graceful path, never-blocks, and both matchers registered) and `tests/fixtures/agent-hooks/claude-pre-compact.json`.
108
+
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — PreCompact hook
4
+ # Fires before Claude Code compacts the context window (manual or auto).
5
+ #
6
+ # PreCompact is a side-effect-only event: per the Claude Code hooks contract it
7
+ # can block compaction but CANNOT inject context into the compacted window — that
8
+ # is SessionStart(source=compact)'s job. What it CAN do is guarantee a rich
9
+ # session summary is persisted *before* the window collapses, instead of racing
10
+ # the async background enricher that the Stop hook queues with nohup.
11
+ #
12
+ # So this hook runs scripts/enrich-summary.sh SYNCHRONOUSLY. PreCompact fires
13
+ # rarely (once per compaction), so it can afford the LLM enrichment call that the
14
+ # fast Stop path deliberately defers for speed. It NEVER blocks compaction (always
15
+ # exits 0) and NEVER overwrites an agent-authored `eagle-mem session save`, which
16
+ # is already the richest capture and already survives compaction via SessionStart.
17
+ # ═══════════════════════════════════════════════════════════
18
+ set +e
19
+ [ "${EAGLE_MEM_DISABLE_HOOKS:-}" = "1" ] && exit 0
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+ LIB_DIR="$SCRIPT_DIR/../lib"
23
+
24
+ . "$LIB_DIR/common.sh"
25
+ . "$LIB_DIR/db.sh"
26
+
27
+ eagle_ensure_db
28
+
29
+ input=$(eagle_read_stdin)
30
+ [ -z "$input" ] && exit 0
31
+
32
+ IFS=$'\x1f' read -r session_id cwd transcript_path trigger <<< \
33
+ "$(echo "$input" | jq -r '[.session_id, .cwd, .transcript_path, .trigger] | map(. // "") | join("")')"
34
+ agent=$(eagle_agent_source_from_json "$input")
35
+
36
+ [ -z "$session_id" ] && exit 0
37
+
38
+ project=$(eagle_project_from_hook_input "$input")
39
+ [ -z "$project" ] && exit 0
40
+
41
+ eagle_hook_observability_begin "$input" "PreCompact"
42
+ eagle_log "INFO" "PreCompact: trigger=${trigger:-unknown} session=$session_id project=$project — capturing summary before compaction"
43
+
44
+ # Bound any synchronous work. The agent_cli provider shells out to a nested
45
+ # claude/codex CLI with NO internal timeout (provider.sh bounds only the
46
+ # curl-based API calls), so a hung provider could otherwise stall compaction up
47
+ # to the Claude Code hook timeout and be killed mid-write. We cap it and proceed;
48
+ # a missed synchronous enrichment is still covered by Stop's background path and
49
+ # the next SessionStart. Portable across macOS (timeout may be absent).
50
+ EAGLE_PRECOMPACT_TIMEOUT="${EAGLE_MEM_PRECOMPACT_TIMEOUT:-45}"
51
+ eagle_run_bounded() {
52
+ local secs="$1"; shift
53
+ if command -v timeout >/dev/null 2>&1; then
54
+ timeout "${secs}s" "$@"
55
+ elif command -v gtimeout >/dev/null 2>&1; then
56
+ gtimeout "${secs}s" "$@"
57
+ else
58
+ "$@" &
59
+ local pid=$!
60
+ ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) >/dev/null 2>&1 &
61
+ local watcher=$!
62
+ wait "$pid" 2>/dev/null
63
+ local rc=$?
64
+ kill -TERM "$watcher" 2>/dev/null
65
+ return "$rc"
66
+ fi
67
+ }
68
+
69
+ # Session may not exist yet if SessionStart didn't fire (or fired for a sibling).
70
+ eagle_upsert_session "$session_id" "$project" "$cwd" "" "" "$agent"
71
+
72
+ emit_capture_event() {
73
+ local captured="$1" mode="$2"
74
+ eagle_insert_event "$project" "$session_id" "$agent" "compaction_capture" "" "PreCompact" "ok" \
75
+ "$(jq -nc --arg trigger "${trigger:-}" --arg mode "$mode" --argjson captured "$captured" \
76
+ '{trigger:$trigger, captured:$captured, mode:$mode}')" >/dev/null 2>&1 || true
77
+ eagle_hook_observability_set_detail \
78
+ "$(jq -nc --arg trigger "${trigger:-}" --arg mode "$mode" --argjson captured "$captured" \
79
+ '{trigger:$trigger, captured:$captured, mode:$mode}')"
80
+ }
81
+
82
+ # An agent-authored `eagle-mem session save` is the richest possible capture and
83
+ # is authoritative. If one already exists for this session it already survives
84
+ # compaction (SessionStart reloads it), so don't spend an LLM call or risk a
85
+ # write race against it.
86
+ existing_source=$(eagle_summary_capture_source "$session_id" 2>/dev/null || true)
87
+ if [ "$existing_source" = "agent" ]; then
88
+ eagle_log "INFO" "PreCompact: agent-authored summary already present for session=$session_id — skipping enrichment"
89
+ emit_capture_event false "agent-summary-present"
90
+ eagle_hook_observability_complete 0
91
+ exit 0
92
+ fi
93
+
94
+ # Latest assistant text from the transcript (Claude format). The same surface the
95
+ # Stop hook hands the enricher; enrich-summary.sh bounds it to a tail excerpt
96
+ # before sending it to the provider.
97
+ text_content=""
98
+ if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then
99
+ text_content=$(jq -r -s '
100
+ [.[] | select(.type == "assistant")]
101
+ | (.[-5:] // [])
102
+ | [.[] | .message.content[]? | select(.type == "text") | .text]
103
+ | join("\n")
104
+ ' "$transcript_path" 2>/dev/null)
105
+ fi
106
+
107
+ captured=false
108
+ mode="no-text"
109
+ if [ -n "$text_content" ]; then
110
+ provider=$(eagle_config_get "provider" "type" "none" 2>/dev/null)
111
+ if [ "$provider" = "none" ]; then
112
+ # No provider — the Stop hook's heuristic summary already covers this
113
+ # session and survives compaction; nothing richer to do here.
114
+ mode="no-provider"
115
+ eagle_log "INFO" "PreCompact: no provider configured — Stop heuristic summary covers session=$session_id"
116
+ else
117
+ mkdir -p "$EAGLE_MEM_DIR/tmp" 2>/dev/null || true
118
+ enrich_job=$(mktemp "$EAGLE_MEM_DIR/tmp/precompact-enrich.XXXXXX.json" 2>/dev/null)
119
+ if [ -n "$enrich_job" ]; then
120
+ # Redact secrets BEFORE persisting/sending the transcript text.
121
+ enrich_text=$(printf '%s' "$text_content" | eagle_redact)
122
+ jq -cn \
123
+ --arg session_id "$session_id" \
124
+ --arg project "$project" \
125
+ --arg agent "$agent" \
126
+ --arg text "$enrich_text" \
127
+ '{session_id:$session_id, project:$project, agent:$agent, text:$text}' > "$enrich_job"
128
+
129
+ enrich_script="$SCRIPT_DIR/../scripts/enrich-summary.sh"
130
+ if [ -f "$enrich_script" ]; then
131
+ # SYNCHRONOUS (not nohup): the point of PreCompact is to land the
132
+ # rich summary before the window collapses. enrich-summary.sh
133
+ # respects capture_source precedence (fill-only over an agent row),
134
+ # exits 0 on provider/LLM failure, and removes its own job file.
135
+ # Time-bounded so a hung provider can never stall compaction.
136
+ eagle_run_bounded "$EAGLE_PRECOMPACT_TIMEOUT" \
137
+ env EAGLE_MEM_DISABLE_HOOKS=1 EAGLE_AGENT_SOURCE="$agent" EAGLE_AGENT_CWD="$cwd" \
138
+ bash "$enrich_script" "$enrich_job" >/dev/null 2>&1
139
+ erc=$?
140
+ # Clean up the job file if enrich-summary was killed mid-run (it
141
+ # removes its own on a normal exit).
142
+ rm -f "$enrich_job" 2>/dev/null || true
143
+ if [ "$erc" -eq 0 ]; then
144
+ captured=true
145
+ mode="enriched"
146
+ eagle_log "INFO" "PreCompact: synchronous enrichment complete for session=$session_id provider=$provider"
147
+ else
148
+ mode="enrich-timeout-or-error"
149
+ eagle_log "WARN" "PreCompact: synchronous enrichment did not complete (rc=$erc) for session=$session_id — Stop background path will cover it"
150
+ fi
151
+ else
152
+ rm -f "$enrich_job" 2>/dev/null || true
153
+ mode="enricher-missing"
154
+ eagle_log "WARN" "PreCompact: enrichment script missing: $enrich_script"
155
+ fi
156
+ fi
157
+ fi
158
+ fi
159
+
160
+ emit_capture_event "$captured" "$mode"
161
+ eagle_hook_observability_complete 0
162
+ exit 0
package/lib/common.sh CHANGED
@@ -456,9 +456,12 @@ eagle_get_session_project_light() {
456
456
 
457
457
  local sid_sql project
458
458
  sid_sql=$(eagle_sql_escape "$session_id")
459
- # busy_timeout so a momentary SQLITE_BUSY waits for the lock instead of
460
- # exiting non-zero and being misread as "session has no project" (fail-open).
461
- project=$("$sqlite_bin" "$EAGLE_MEM_DB" "PRAGMA busy_timeout=10000; SELECT project FROM sessions WHERE id = '$sid_sql' AND project != '' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
459
+ # busy_timeout (via the silent `-cmd ".timeout"` dot-command, NOT an inline
460
+ # `PRAGMA busy_timeout=N;`) so a momentary SQLITE_BUSY waits for the lock
461
+ # instead of exiting non-zero and being misread as "session has no project"
462
+ # (fail-open). An inline value-setting PRAGMA echoes its value ("10000") as
463
+ # the first output row, which `awk 'NF{...}'` would then return as the project.
464
+ project=$("$sqlite_bin" -cmd ".timeout 10000" "$EAGLE_MEM_DB" "SELECT project FROM sessions WHERE id = '$sid_sql' AND project != '' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
462
465
  [ -n "$project" ] || return 1
463
466
  printf '%s\n' "$project"
464
467
  }
@@ -481,9 +484,12 @@ eagle_project_has_table_row() {
481
484
 
482
485
  local project_sql found
483
486
  project_sql=$(eagle_sql_escape "$project")
484
- # busy_timeout so a momentary SQLITE_BUSY waits for the lock instead of
485
- # exiting non-zero and being misread as "row doesn't exist" (fail-open).
486
- found=$("$sqlite_bin" "$EAGLE_MEM_DB" "PRAGMA busy_timeout=10000; SELECT 1 FROM $table WHERE project = '$project_sql' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
487
+ # busy_timeout (via the silent `-cmd ".timeout"` dot-command, NOT an inline
488
+ # `PRAGMA busy_timeout=N;`) so a momentary SQLITE_BUSY waits for the lock
489
+ # instead of exiting non-zero and being misread as "row doesn't exist"
490
+ # (fail-open). An inline value-setting PRAGMA echoes its value ("10000") as
491
+ # the first output row, which would then fail the `= "1"` test below.
492
+ found=$("$sqlite_bin" -cmd ".timeout 10000" "$EAGLE_MEM_DB" "SELECT 1 FROM $table WHERE project = '$project_sql' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
487
493
  [ "$found" = "1" ]
488
494
  }
489
495
 
package/lib/hooks.sh CHANGED
@@ -116,6 +116,7 @@ eagle_register_claude_hooks() {
116
116
  eagle_clean_hook_entries "$settings" "Stop" "$EAGLE_MEM_DIR/hooks/stop.sh"
117
117
  eagle_clean_hook_entries "$settings" "PostToolUse" "$EAGLE_MEM_DIR/hooks/post-tool-use.sh"
118
118
  eagle_clean_hook_entries "$settings" "PreToolUse" "$EAGLE_MEM_DIR/hooks/pre-tool-use.sh"
119
+ eagle_clean_hook_entries "$settings" "PreCompact" "$EAGLE_MEM_DIR/hooks/pre-compact.sh"
119
120
 
120
121
  # ${V:+label} expands to the label only in verbose mode; otherwise to "",
121
122
  # which makes eagle_patch_hook silent (it only prints when given a description).
@@ -127,6 +128,11 @@ eagle_register_claude_hooks() {
127
128
  eagle_patch_hook "$settings" "SessionEnd" "" "$EAGLE_MEM_DIR/hooks/session-end.sh" "${V:+SessionEnd hook}"
128
129
  eagle_patch_hook "$settings" "UserPromptSubmit" "" "$EAGLE_MEM_DIR/hooks/user-prompt-submit.sh" "${V:+UserPromptSubmit hook}"
129
130
  eagle_patch_hook "$settings" "PreToolUse" "Bash|Read|Edit|Write" "$EAGLE_MEM_DIR/hooks/pre-tool-use.sh" "${V:+PreToolUse hook}"
131
+ # PreCompact's matcher is the trigger itself ("manual" | "auto"); register both
132
+ # so auto-compaction — the gap users actually hit — also captures before the
133
+ # window collapses. The hook reads `trigger` from stdin for telemetry.
134
+ eagle_patch_hook "$settings" "PreCompact" "manual" "$EAGLE_MEM_DIR/hooks/pre-compact.sh" "${V:+PreCompact hook (manual)}"
135
+ eagle_patch_hook "$settings" "PreCompact" "auto" "$EAGLE_MEM_DIR/hooks/pre-compact.sh" "${V:+PreCompact hook (auto)}"
130
136
 
131
137
  # Allow agent-issued session capture to run without a permission prompt.
132
138
  if eagle_patch_permission_allow "$settings" "Bash(eagle-mem session save:*)"; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eagle-mem",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "description": "Shared memory, release guardrails, RTK token protection, and worker lanes for Claude Code, Codex, OpenCode, Grok, and Google Antigravity",
5
5
  "bin": {
6
6
  "eagle-mem": "bin/eagle-mem"
@@ -65,11 +65,13 @@ eagle_mem_statusline_stats() {
65
65
  project_scope=$(eagle_recall_project_scope_from_cwd "${current_dir:-$project_dir}" "$project_key")
66
66
  project_condition=$(eagle_sql_project_scope_condition "project" "$project_scope")
67
67
 
68
- # busy_timeout so a momentary SQLITE_BUSY (this is the hottest standalone
69
- # query during live sessions) waits for the lock instead of exiting non-zero,
70
- # which would otherwise escalate to an integrity-status mislabel below.
71
- stats=$("$sqlite_bin" "$em_db" "PRAGMA busy_timeout=10000;
72
- SELECT
68
+ # busy_timeout (via the silent `-cmd ".timeout"` dot-command, NOT an inline
69
+ # `PRAGMA busy_timeout=N;`) so a momentary SQLITE_BUSY (this is the hottest
70
+ # standalone query during live sessions) waits for the lock instead of exiting
71
+ # non-zero, which would otherwise escalate to an integrity-status mislabel below.
72
+ # An inline value-setting PRAGMA echoes "10000" as the first output row, which
73
+ # the `IFS='|' read` below would parse as sessions=10000, memories=0, last=never.
74
+ stats=$("$sqlite_bin" -cmd ".timeout 10000" "$em_db" "SELECT
73
75
  COUNT(*) || '|' ||
74
76
  (SELECT COUNT(*) FROM agent_memories WHERE $project_condition) || '|' ||
75
77
  COALESCE(MAX(COALESCE(last_activity_at, started_at)), 'never')
package/scripts/test.sh CHANGED
@@ -96,6 +96,8 @@ run_check "Test Runner No-Abort (failing check does not kill the suite under set
96
96
  run_check "Antigravity Hook (native Python SDK lifecycle, mocked)" "( command -v python3 >/dev/null 2>&1 || exit 2; python3 \"$SCRIPTS_DIR/../tests/test_antigravity_hook.py\" )"
97
97
  run_check "Release Gate Parity (eagle-mem gate: blocks pending, fails open, pre-push install)" "bash \"$SCRIPTS_DIR/../tests/test_release_gate_prepush.sh\""
98
98
  run_check "Command Rule Provenance (curator rules tagged + trust_learned_rules gate)" "bash \"$SCRIPTS_DIR/../tests/test_command_rule_provenance.sh\""
99
+ run_check "busy_timeout Echo (no PRAGMA value leaks into project/row/statusline reads)" "bash \"$SCRIPTS_DIR/../tests/test_busy_timeout_echo.sh\""
100
+ run_check "PreCompact Capture (synchronous rich summary before compaction, both matchers, no agent clobber)" "bash \"$SCRIPTS_DIR/../tests/test_precompact_capture.sh\""
99
101
 
100
102
  echo ""
101
103
  if [ "$errors" -eq 0 ]; then
@@ -40,7 +40,7 @@ fi
40
40
 
41
41
  if [ -f "$SETTINGS" ] && command -v jq &>/dev/null; then
42
42
  settings_backup=$(eagle_backup_user_file "$SETTINGS" 2>/dev/null || true)
43
- for event in SessionStart Stop PostToolUse PreToolUse SessionEnd UserPromptSubmit; do
43
+ for event in SessionStart Stop PostToolUse PreToolUse PreCompact SessionEnd UserPromptSubmit TaskCreated TaskCompleted; do
44
44
  if jq -e ".hooks.${event}" "$SETTINGS" &>/dev/null; then
45
45
  tmp=$(mktemp)
46
46
  jq ".hooks.${event} = [.hooks.${event}[]? | select(any(.hooks[]?; .command | contains(\"eagle-mem\")) | not)]" "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
@@ -0,0 +1,7 @@
1
+ {
2
+ "session_id": "claude-precompact-fixture-session",
3
+ "transcript_path": "/tmp/eagle-mem-precompact-fixture.jsonl",
4
+ "cwd": "/tmp/eagle-mem-precompact-fixture",
5
+ "hook_event_name": "PreCompact",
6
+ "trigger": "auto"
7
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — busy_timeout echo regression test
4
+ #
5
+ # Root cause guarded here: an inline value-setting `PRAGMA busy_timeout=N;`
6
+ # run in the SAME sqlite3 invocation as a SELECT echoes its value ("10000")
7
+ # as the FIRST output row. Any caller that reads the first line then mis-reads
8
+ # the timeout value as data:
9
+ # - eagle_get_session_project_light -> returns "10000" as the project,
10
+ # mis-filing every session under a phantom "10000" project.
11
+ # - eagle_project_has_table_row -> `[ "10000" = "1" ]` is false, so it
12
+ # ALWAYS reports "no row", breaking ancestor-project repair.
13
+ # - statusline stats -> `IFS='|' read` parses "10000" as sessions, 0 memories.
14
+ # The fix sets the timeout via the silent `-cmd ".timeout"` dot-command, which
15
+ # emits no output. This test reproduces the failure (buggy code returns 10000
16
+ # / always-false) and structurally guards against re-introducing the shape.
17
+ # ═══════════════════════════════════════════════════════════
18
+ set -uo pipefail
19
+
20
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
21
+ tmp_dir=$(mktemp -d)
22
+ trap 'rm -rf "$tmp_dir"' EXIT
23
+
24
+ export EAGLE_MEM_DIR="$tmp_dir/em"
25
+ export EAGLE_AGENT_SOURCE="claude-code"
26
+ export EAGLE_MEM_DISABLE_HOOKS=1
27
+ mkdir -p "$EAGLE_MEM_DIR"
28
+
29
+ pass=0; fail=0
30
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
31
+ bad() { echo " FAIL: $1" >&2; fail=$((fail+1)); }
32
+
33
+ bash "$ROOT_DIR/db/migrate.sh" >/dev/null 2>&1
34
+
35
+ . "$ROOT_DIR/lib/common.sh"
36
+ . "$ROOT_DIR/lib/db.sh"
37
+
38
+ # ── eagle_get_session_project_light returns the real project ────────────────
39
+ SID="sess-busy-timeout-001"
40
+ PROJ="personal_projects/eagle-mem"
41
+ eagle_upsert_session "$SID" "$PROJ" "$tmp_dir" "" "test" "claude-code"
42
+
43
+ got=$(eagle_get_session_project_light "$SID")
44
+ if [ "$got" = "10000" ]; then
45
+ bad "eagle_get_session_project_light returned the busy_timeout echo ('10000') as the project"
46
+ elif [ "$got" = "$PROJ" ]; then
47
+ ok "eagle_get_session_project_light returns the real project ('$got')"
48
+ else
49
+ bad "eagle_get_session_project_light returned unexpected value: '$got' (expected '$PROJ')"
50
+ fi
51
+
52
+ # Unknown session must resolve to nothing (rc=1), never the echo value.
53
+ if unknown=$(eagle_get_session_project_light "sess-does-not-exist-999"); then
54
+ bad "eagle_get_session_project_light succeeded for unknown session, returned '$unknown'"
55
+ else
56
+ ok "eagle_get_session_project_light fails closed for unknown session"
57
+ fi
58
+
59
+ # ── eagle_project_has_table_row: true for present, false for absent ──────────
60
+ if eagle_project_has_table_row "sessions" "$PROJ"; then
61
+ ok "eagle_project_has_table_row finds the existing project row"
62
+ else
63
+ bad "eagle_project_has_table_row reports 'no row' for a project that HAS a row (busy_timeout echo broke the '= 1' test)"
64
+ fi
65
+
66
+ if eagle_project_has_table_row "sessions" "no/such/project-xyz"; then
67
+ bad "eagle_project_has_table_row reports a row for a project with none"
68
+ else
69
+ ok "eagle_project_has_table_row correctly reports no row for an absent project"
70
+ fi
71
+
72
+ # ── Structural guard: no inline value-setting PRAGMA before a parsed SELECT ──
73
+ # (comment lines are stripped so the explanatory comments above don't match)
74
+ common_hit=$(grep -vE '^[[:space:]]*#' "$ROOT_DIR/lib/common.sh" | grep -nE 'busy_timeout=[0-9]+;[[:space:]]*SELECT' || true)
75
+ if [ -n "$common_hit" ]; then
76
+ bad "lib/common.sh re-introduced an inline 'PRAGMA busy_timeout=N; SELECT' (echoes the value): $common_hit"
77
+ else
78
+ ok "lib/common.sh has no inline value-setting PRAGMA before a SELECT"
79
+ fi
80
+
81
+ statusline_hit=$(grep -vE '^[[:space:]]*#' "$ROOT_DIR/scripts/statusline-em.sh" | grep -nE 'busy_timeout' || true)
82
+ if [ -n "$statusline_hit" ]; then
83
+ bad "scripts/statusline-em.sh re-introduced an inline PRAGMA busy_timeout (use -cmd \".timeout\"): $statusline_hit"
84
+ else
85
+ ok "scripts/statusline-em.sh sets the timeout via the silent -cmd \".timeout\" dot-command"
86
+ fi
87
+
88
+ echo
89
+ echo "busy_timeout echo regression: $pass passed, $fail failed"
90
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — PreCompact capture regression test
4
+ #
5
+ # Guards the contract of hooks/pre-compact.sh (synchronous rich capture before
6
+ # Claude Code compacts the window):
7
+ # 1. Registers for BOTH "manual" and "auto" matchers (auto is the real gap).
8
+ # 2. With a provider, runs enrich-summary.sh SYNCHRONOUSLY so the rich summary
9
+ # is persisted before compaction (capture_source = enrich).
10
+ # 3. Never overwrites an agent-authored summary (capture_source = agent).
11
+ # 4. Degrades gracefully with no provider, and ALWAYS exits 0 (never blocks
12
+ # compaction — it must never exit 2).
13
+ # ═══════════════════════════════════════════════════════════
14
+ set -uo pipefail
15
+
16
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
17
+ tmp_dir=$(mktemp -d)
18
+ trap 'rm -rf "$tmp_dir"' EXIT
19
+
20
+ export EAGLE_MEM_DIR="$tmp_dir/em"
21
+ export EAGLE_AGENT_SOURCE="claude-code"
22
+ mkdir -p "$EAGLE_MEM_DIR"
23
+
24
+ pass=0; fail=0
25
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
26
+ bad() { echo " FAIL: $1" >&2; fail=$((fail+1)); }
27
+
28
+ bash "$ROOT_DIR/db/migrate.sh" >/dev/null 2>&1
29
+
30
+ . "$ROOT_DIR/lib/common.sh"
31
+ . "$ROOT_DIR/lib/db.sh"
32
+ . "$ROOT_DIR/lib/hooks.sh"
33
+
34
+ PRECOMPACT="$ROOT_DIR/hooks/pre-compact.sh"
35
+
36
+ # A minimal Claude-format transcript with assistant text for the enricher.
37
+ transcript="$tmp_dir/transcript.jsonl"
38
+ {
39
+ printf '%s\n' '{"type":"user","message":{"content":"build a precompact hook"}}'
40
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"I implemented the PreCompact hook with synchronous enrichment before compaction."}]}}'
41
+ } > "$transcript"
42
+
43
+ mk_input() {
44
+ local sid="$1" trigger="$2"
45
+ jq -nc \
46
+ --arg sid "$sid" \
47
+ --arg cwd "$tmp_dir/work" \
48
+ --arg tp "$transcript" \
49
+ --arg trig "$trigger" \
50
+ '{session_id:$sid, cwd:$cwd, transcript_path:$tp, hook_event_name:"PreCompact", trigger:$trig}'
51
+ }
52
+
53
+ # Resolve the project the hook will derive, so parent-side inserts line up.
54
+ PROJ=$(eagle_project_from_hook_input "$(mk_input "probe" "auto")")
55
+ [ -n "$PROJ" ] || PROJ="precompact/test"
56
+
57
+ # ── 1. Registration: both matchers ──────────────────────────────────────────
58
+ settings="$tmp_dir/settings.json"
59
+ echo '{}' > "$settings"
60
+ eagle_register_claude_hooks "$settings" >/dev/null 2>&1
61
+ manual_cmd=$(jq -r '.hooks.PreCompact[]? | select(.matcher=="manual") | .hooks[].command' "$settings" 2>/dev/null)
62
+ auto_cmd=$(jq -r '.hooks.PreCompact[]? | select(.matcher=="auto") | .hooks[].command' "$settings" 2>/dev/null)
63
+ if echo "$manual_cmd" | grep -q 'pre-compact.sh'; then
64
+ ok "PreCompact registered for matcher 'manual'"
65
+ else
66
+ bad "PreCompact not registered for matcher 'manual'"
67
+ fi
68
+ if echo "$auto_cmd" | grep -q 'pre-compact.sh'; then
69
+ ok "PreCompact registered for matcher 'auto' (auto-compaction covered)"
70
+ else
71
+ bad "PreCompact not registered for matcher 'auto'"
72
+ fi
73
+
74
+ # ── 2. No provider: graceful, exits 0, writes no enriched summary ────────────
75
+ SID2="precompact-noprovider-001"
76
+ eagle_upsert_session "$SID2" "$PROJ" "$tmp_dir/work" "" "test" "claude-code"
77
+ mk_input "$SID2" "auto" | bash "$PRECOMPACT"; rc2=$?
78
+ if [ "$rc2" -eq 0 ]; then
79
+ ok "no-provider run exits 0 (never blocks compaction)"
80
+ else
81
+ bad "no-provider run exited $rc2 (must be 0; exit 2 would block compaction)"
82
+ fi
83
+ src2=$(eagle_summary_capture_source "$SID2" 2>/dev/null || true)
84
+ if [ -z "$src2" ]; then
85
+ ok "no-provider run writes no summary (Stop already covers it)"
86
+ else
87
+ bad "no-provider run unexpectedly wrote a summary (capture_source='$src2')"
88
+ fi
89
+
90
+ # ── 3. Agent-authored summary is never clobbered ────────────────────────────
91
+ SID3="precompact-agentrow-001"
92
+ eagle_upsert_session "$SID3" "$PROJ" "$tmp_dir/work" "" "test" "claude-code"
93
+ eagle_insert_summary "$SID3" "$PROJ" "agent req" "" "" "AGENT_ORIGINAL_COMPLETED" "" "[]" "[]" "" "" "" "" "claude-code" "agent" >/dev/null 2>&1
94
+ mk_input "$SID3" "manual" | bash "$PRECOMPACT"; rc3=$?
95
+ src3=$(eagle_summary_capture_source "$SID3" 2>/dev/null || true)
96
+ comp3=$(eagle_db "SELECT completed FROM summaries WHERE session_id = '$(eagle_sql_escape "$SID3")' LIMIT 1;" 2>/dev/null)
97
+ if [ "$rc3" -eq 0 ] && [ "$src3" = "agent" ] && [ "$comp3" = "AGENT_ORIGINAL_COMPLETED" ]; then
98
+ ok "agent-authored summary preserved (capture_source=agent, content intact)"
99
+ else
100
+ bad "agent summary perturbed: rc=$rc3 source='$src3' completed='$comp3'"
101
+ fi
102
+
103
+ # ── 4. With a provider: synchronous enrichment writes a rich summary ─────────
104
+ fake_bin="$tmp_dir/bin"
105
+ mkdir -p "$fake_bin"
106
+ cat > "$fake_bin/claude" <<'SH'
107
+ #!/usr/bin/env bash
108
+ cat <<'OUT'
109
+ REQUEST:
110
+ Build a PreCompact hook for Eagle Mem
111
+ COMPLETED:
112
+ Added PRECOMPACT_SYNC_CAPTURE before the window collapses
113
+ DECISIONS:
114
+ - Reused enrich-summary synchronously — why: guarantee rich capture before compaction
115
+ GOTCHAS:
116
+ - PreCompact cannot inject context, only side effects
117
+ KEY_FILES:
118
+ hooks/pre-compact.sh
119
+ OUT
120
+ SH
121
+ chmod +x "$fake_bin/claude"
122
+ cat > "$EAGLE_MEM_DIR/config.toml" <<'TOML'
123
+ [provider]
124
+ type = "agent_cli"
125
+ fallback = "auto"
126
+
127
+ [agent_cli]
128
+ preferred = "claude"
129
+ codex_model = ""
130
+ claude_model = ""
131
+ TOML
132
+
133
+ SID4="precompact-enrich-001"
134
+ eagle_upsert_session "$SID4" "$PROJ" "$tmp_dir/work" "" "test" "claude-code"
135
+ mk_input "$SID4" "manual" | PATH="$fake_bin:$PATH" bash "$PRECOMPACT"; rc4=$?
136
+ src4=$(eagle_summary_capture_source "$SID4" 2>/dev/null || true)
137
+ comp4=$(eagle_db "SELECT completed FROM summaries WHERE session_id = '$(eagle_sql_escape "$SID4")' LIMIT 1;" 2>/dev/null)
138
+ if [ "$rc4" -eq 0 ]; then
139
+ ok "provider run exits 0"
140
+ else
141
+ bad "provider run exited $rc4 (must be 0)"
142
+ fi
143
+ if [ "$src4" = "enrich" ] && echo "$comp4" | grep -q 'PRECOMPACT_SYNC_CAPTURE'; then
144
+ ok "synchronous enrichment wrote a rich summary before compaction (capture_source=enrich)"
145
+ else
146
+ bad "synchronous enrichment did not write the expected summary: source='$src4' completed='$comp4'"
147
+ fi
148
+
149
+ # ── 5. Structural guard: hook never exits 2 (would block compaction) ─────────
150
+ if grep -nE '^[[:space:]]*exit[[:space:]]+2' "$PRECOMPACT" >/dev/null 2>&1; then
151
+ bad "pre-compact.sh contains 'exit 2' — PreCompact must never block compaction"
152
+ else
153
+ ok "pre-compact.sh has no 'exit 2' (cannot block compaction)"
154
+ fi
155
+
156
+ echo
157
+ echo "precompact capture: $pass passed, $fail failed"
158
+ [ "$fail" -eq 0 ]