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 +29 -0
- package/docs/agent-compatibility/claude-code.md +23 -1
- package/hooks/pre-compact.sh +162 -0
- package/lib/common.sh +12 -6
- package/lib/hooks.sh +6 -0
- package/package.json +1 -1
- package/scripts/statusline-em.sh +7 -5
- package/scripts/test.sh +2 -0
- package/scripts/uninstall.sh +1 -1
- package/tests/fixtures/agent-hooks/claude-pre-compact.json +7 -0
- package/tests/test_busy_timeout_echo.sh +90 -0
- package/tests/test_precompact_capture.sh +158 -0
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-
|
|
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
|
|
460
|
-
#
|
|
461
|
-
|
|
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
|
|
485
|
-
#
|
|
486
|
-
|
|
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.
|
|
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"
|
package/scripts/statusline-em.sh
CHANGED
|
@@ -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
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
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
|
package/scripts/uninstall.sh
CHANGED
|
@@ -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,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 ]
|