@memnexus-ai/cli 1.7.220 → 1.7.222
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/dist/commands/memories.d.ts +2 -2
- package/dist/commands/memories.d.ts.map +1 -1
- package/dist/commands/memories.js +190 -91
- package/dist/commands/memories.js.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/errors.d.ts +47 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +172 -48
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/retry.d.ts +83 -1
- package/dist/lib/retry.d.ts.map +1 -1
- package/dist/lib/retry.js +143 -3
- package/dist/lib/retry.js.map +1 -1
- package/dist/lib/spinner.d.ts +32 -2
- package/dist/lib/spinner.d.ts.map +1 -1
- package/dist/lib/spinner.js +40 -2
- package/dist/lib/spinner.js.map +1 -1
- package/hooks/capture-hooks.test.sh +465 -0
- package/hooks/capture-precompact.sh +11 -1
- package/hooks/capture-session-end.sh +101 -7
- package/package.json +2 -2
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# capture-hooks.test.sh — regression suite for the v4.04 error-honesty fixes in
|
|
3
|
+
# the customer-shipped capture hooks (capture-session-end.sh,
|
|
4
|
+
# capture-precompact.sh) and the dev-tools auto-save hook.
|
|
5
|
+
#
|
|
6
|
+
# Run with: bash cli/hooks/capture-hooks.test.sh
|
|
7
|
+
# Exit: 0 = all pass, 1 = any fail.
|
|
8
|
+
#
|
|
9
|
+
# What is under test:
|
|
10
|
+
# * a FAILED `mx memories create` must leave the debounce watermark alone, so
|
|
11
|
+
# the segment is retried instead of being discarded forever
|
|
12
|
+
# * a SUCCESSFUL create must advance the watermark
|
|
13
|
+
# * the project-hash fallback chain must actually fall back (the old form
|
|
14
|
+
# piped into awk and therefore tested awk's exit status, never the hasher's)
|
|
15
|
+
# * with no hasher at all, the hook must skip rather than write a state file
|
|
16
|
+
# shared by every project
|
|
17
|
+
# * every hook still exits 0 in all of these cases (fail-open with Claude Code)
|
|
18
|
+
set -u
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
22
|
+
SESSION_END_HOOK="$SCRIPT_DIR/capture-session-end.sh"
|
|
23
|
+
PRECOMPACT_HOOK="$SCRIPT_DIR/capture-precompact.sh"
|
|
24
|
+
AUTOSAVE_HOOK="$REPO_ROOT/dev-tools/claude-hooks/auto-save-memory.sh"
|
|
25
|
+
|
|
26
|
+
PASS=0
|
|
27
|
+
FAIL=0
|
|
28
|
+
|
|
29
|
+
ok() {
|
|
30
|
+
PASS=$((PASS + 1))
|
|
31
|
+
echo " PASS: $1"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
nope() {
|
|
35
|
+
FAIL=$((FAIL + 1))
|
|
36
|
+
echo " FAIL: $1"
|
|
37
|
+
[[ $# -gt 1 ]] && echo " $2"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
assert_eq() { # <expected> <actual> <label>
|
|
41
|
+
if [[ "$1" == "$2" ]]; then ok "$3"; else nope "$3" "expected [$1], got [$2]"; fi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assert_contains() { # <haystack> <needle> <label>
|
|
45
|
+
if [[ "$1" == *"$2"* ]]; then ok "$3"; else nope "$3" "missing [$2] in: $1"; fi
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# ── Fixtures ────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
# A stub `mx` whose exit status is controlled by MX_STUB_EXIT. Every call is
|
|
51
|
+
# appended to MX_STUB_LOG so a test can assert that no create was even
|
|
52
|
+
# attempted (the bounded-retry cap).
|
|
53
|
+
make_stub_bin() { # <dir>
|
|
54
|
+
local bin="$1"
|
|
55
|
+
mkdir -p "$bin"
|
|
56
|
+
cat >"$bin/mx" <<'STUB'
|
|
57
|
+
#!/bin/bash
|
|
58
|
+
[[ -n "${MX_STUB_LOG:-}" ]] && echo "call" >>"$MX_STUB_LOG"
|
|
59
|
+
exit "${MX_STUB_EXIT:-0}"
|
|
60
|
+
STUB
|
|
61
|
+
chmod +x "$bin/mx"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# A transcript with enough lines (>100) and tool calls (>=5) to clear the
|
|
65
|
+
# hooks' debounce and triviality gates.
|
|
66
|
+
make_transcript() { # <path> <lines>
|
|
67
|
+
local path="$1" lines="${2:-150}" i
|
|
68
|
+
: >"$path"
|
|
69
|
+
for ((i = 0; i < lines; i++)); do
|
|
70
|
+
printf '%s\n' '{"type":"user","message":{"content":"do the thing"}}' >>"$path"
|
|
71
|
+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"/tmp/x.ts"}},{"type":"text","text":"done"}]}}' >>"$path"
|
|
72
|
+
done
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Run capture-session-end.sh in an isolated HOME/state dir.
|
|
76
|
+
# Echoes the watermark file's contents (empty when absent).
|
|
77
|
+
run_session_end() { # <workdir> <path-override> ; uses MX_STUB_EXIT
|
|
78
|
+
local work="$1" path_override="$2"
|
|
79
|
+
local transcript="$work/transcript.jsonl"
|
|
80
|
+
( cd "$work" && PATH="$path_override" HOME="$work/home" XDG_RUNTIME_DIR="$work/state" \
|
|
81
|
+
MX_STUB_LOG="$work/mx-calls.log" \
|
|
82
|
+
bash "$SESSION_END_HOOK" \
|
|
83
|
+
<<<"{\"transcript_path\":\"$transcript\",\"session_id\":\"s1\",\"stop_hook_active\":false}" \
|
|
84
|
+
2>"$work/stderr.txt" )
|
|
85
|
+
echo "$?" >"$work/exit.txt"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Contents of the watermark file (empty when absent). The sibling ".fails"
|
|
89
|
+
# counter is excluded — it lives next to the watermark and must not be mistaken
|
|
90
|
+
# for it. Globs, not `ls`: an unmatched glob yields the literal pattern, which
|
|
91
|
+
# the -f test filters out.
|
|
92
|
+
state_file_contents() { # <workdir>
|
|
93
|
+
local f
|
|
94
|
+
for f in "$1"/state/.mx-session-capture-*; do
|
|
95
|
+
[[ -f "$f" ]] || continue
|
|
96
|
+
[[ "$f" == *.fails ]] && continue
|
|
97
|
+
cat "$f"
|
|
98
|
+
return 0
|
|
99
|
+
done
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fail_count_contents() { # <workdir>
|
|
104
|
+
local f
|
|
105
|
+
for f in "$1"/state/.mx-session-capture-*.fails; do
|
|
106
|
+
[[ -f "$f" ]] || continue
|
|
107
|
+
cat "$f"
|
|
108
|
+
return 0
|
|
109
|
+
done
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Space-separated basenames of everything in the state dir — failure diagnostics
|
|
114
|
+
# only. Dotfiles are matched explicitly because the state files are hidden.
|
|
115
|
+
list_state_dir() { # <workdir>
|
|
116
|
+
local f out=""
|
|
117
|
+
for f in "$1"/state/* "$1"/state/.[!.]*; do
|
|
118
|
+
[[ -e "$f" ]] || continue
|
|
119
|
+
out+="$(basename "$f") "
|
|
120
|
+
done
|
|
121
|
+
printf '%s' "$out"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Number of `mx` invocations recorded by the stub (0 when it was never called).
|
|
125
|
+
count_mx_calls() { # <workdir>
|
|
126
|
+
local log="$1/mx-calls.log"
|
|
127
|
+
[[ -f "$log" ]] || { echo 0; return 0; }
|
|
128
|
+
wc -l <"$log" | tr -d ' '
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
REAL_PATH="$PATH"
|
|
132
|
+
|
|
133
|
+
# Build a shadow PATH directory: every executable on the current PATH symlinked
|
|
134
|
+
# in, MINUS the named tools. Used to simulate a machine without md5sum (or
|
|
135
|
+
# without any hasher) while leaving the rest of the environment intact.
|
|
136
|
+
make_shadow_path() { # <dir> <tool-to-omit>...
|
|
137
|
+
local dir="$1"; shift
|
|
138
|
+
local omit=" $* "
|
|
139
|
+
local d f name
|
|
140
|
+
mkdir -p "$dir"
|
|
141
|
+
local IFS=:
|
|
142
|
+
for d in $REAL_PATH; do
|
|
143
|
+
[[ -d "$d" ]] || continue
|
|
144
|
+
for f in "$d"/*; do
|
|
145
|
+
[[ -x "$f" && ! -d "$f" ]] || continue
|
|
146
|
+
name="$(basename "$f")"
|
|
147
|
+
[[ "$omit" == *" $name "* ]] && continue
|
|
148
|
+
[[ -e "$dir/$name" ]] && continue
|
|
149
|
+
ln -sf "$f" "$dir/$name"
|
|
150
|
+
done
|
|
151
|
+
done
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
echo "── capture-session-end.sh ──"
|
|
156
|
+
|
|
157
|
+
# 1. Failed create → watermark unchanged.
|
|
158
|
+
work=$(mktemp -d)
|
|
159
|
+
make_stub_bin "$work/bin"
|
|
160
|
+
make_transcript "$work/transcript.jsonl"
|
|
161
|
+
mkdir -p "$work/state" "$work/home"
|
|
162
|
+
MX_STUB_EXIT=1 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
163
|
+
assert_eq "0" "$(cat "$work/exit.txt")" "fail-open: hook exits 0 when create fails"
|
|
164
|
+
assert_eq "" "$(state_file_contents "$work")" "failed create leaves the watermark unwritten"
|
|
165
|
+
assert_contains "$(cat "$work/stderr.txt")" "mx memories create failed" \
|
|
166
|
+
"failed create writes one stderr diagnostic"
|
|
167
|
+
rm -rf "$work"
|
|
168
|
+
|
|
169
|
+
# 2. Successful create → watermark advances to the transcript's line count.
|
|
170
|
+
work=$(mktemp -d)
|
|
171
|
+
make_stub_bin "$work/bin"
|
|
172
|
+
make_transcript "$work/transcript.jsonl"
|
|
173
|
+
mkdir -p "$work/state" "$work/home"
|
|
174
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
175
|
+
assert_eq "0" "$(cat "$work/exit.txt")" "hook exits 0 on success"
|
|
176
|
+
assert_eq "300" "$(state_file_contents "$work")" "successful create advances the watermark"
|
|
177
|
+
rm -rf "$work"
|
|
178
|
+
|
|
179
|
+
# 3. Retry semantics: a failed run followed by a successful one saves the same
|
|
180
|
+
# segment (the watermark only moves on the success).
|
|
181
|
+
work=$(mktemp -d)
|
|
182
|
+
make_stub_bin "$work/bin"
|
|
183
|
+
make_transcript "$work/transcript.jsonl"
|
|
184
|
+
mkdir -p "$work/state" "$work/home"
|
|
185
|
+
MX_STUB_EXIT=1 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
186
|
+
first="$(state_file_contents "$work")"
|
|
187
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
188
|
+
second="$(state_file_contents "$work")"
|
|
189
|
+
assert_eq "" "$first" "first (failed) run: no watermark"
|
|
190
|
+
assert_eq "300" "$second" "second (successful) run: watermark advances — segment was retried"
|
|
191
|
+
rm -rf "$work"
|
|
192
|
+
|
|
193
|
+
# 4. The default hasher is sha256sum (cksum was dropped from the chain: a
|
|
194
|
+
# 32-bit CRC collision would make two projects share a state file).
|
|
195
|
+
work=$(mktemp -d)
|
|
196
|
+
make_stub_bin "$work/bin"
|
|
197
|
+
make_transcript "$work/transcript.jsonl"
|
|
198
|
+
mkdir -p "$work/state" "$work/home"
|
|
199
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
200
|
+
expected_hash=$(printf '%s' "$work" | sha256sum | awk '{print $1}')
|
|
201
|
+
if [[ -f "$work/state/.mx-session-capture-${expected_hash}" ]]; then
|
|
202
|
+
ok "state file is keyed by the sha256 of the project dir"
|
|
203
|
+
else
|
|
204
|
+
nope "state file is keyed by the sha256 of the project dir" \
|
|
205
|
+
"state dir holds: $(list_state_dir "$work")"
|
|
206
|
+
fi
|
|
207
|
+
rm -rf "$work"
|
|
208
|
+
|
|
209
|
+
# 4b. sha256sum absent → the md5sum fallback engages (the old awk-piped chain
|
|
210
|
+
# silently produced an EMPTY hash shared by every project).
|
|
211
|
+
work=$(mktemp -d)
|
|
212
|
+
make_stub_bin "$work/bin"
|
|
213
|
+
make_transcript "$work/transcript.jsonl"
|
|
214
|
+
mkdir -p "$work/state" "$work/home"
|
|
215
|
+
make_shadow_path "$work/nosha" sha256sum
|
|
216
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$work/nosha"
|
|
217
|
+
expected_hash=$(printf '%s' "$work" | md5sum | awk '{print $1}')
|
|
218
|
+
if [[ -f "$work/state/.mx-session-capture-${expected_hash}" ]]; then
|
|
219
|
+
ok "sha256sum absent: md5sum fallback produces the project-specific state file"
|
|
220
|
+
else
|
|
221
|
+
nope "sha256sum absent: md5sum fallback engages" \
|
|
222
|
+
"state dir holds: $(list_state_dir "$work")"
|
|
223
|
+
fi
|
|
224
|
+
rm -rf "$work"
|
|
225
|
+
|
|
226
|
+
# 4c. cksum alone is NOT accepted (32-bit CRC — collisions are constructible).
|
|
227
|
+
work=$(mktemp -d)
|
|
228
|
+
make_stub_bin "$work/bin"
|
|
229
|
+
make_transcript "$work/transcript.jsonl"
|
|
230
|
+
mkdir -p "$work/state" "$work/home"
|
|
231
|
+
make_shadow_path "$work/onlycksum" sha256sum md5sum shasum b2sum
|
|
232
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$work/onlycksum"
|
|
233
|
+
assert_eq "" "$(ls -A "$work/state" 2>/dev/null)" "cksum-only machine: skips rather than using a 32-bit CRC"
|
|
234
|
+
rm -rf "$work"
|
|
235
|
+
|
|
236
|
+
# 5. No hasher at all → skip with a diagnostic, and never write a shared
|
|
237
|
+
# (hash-less) state file that would suppress every other project.
|
|
238
|
+
work=$(mktemp -d)
|
|
239
|
+
make_stub_bin "$work/bin"
|
|
240
|
+
make_transcript "$work/transcript.jsonl"
|
|
241
|
+
mkdir -p "$work/state" "$work/home"
|
|
242
|
+
make_shadow_path "$work/nohash" sha256sum md5sum shasum b2sum cksum
|
|
243
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$work/nohash"
|
|
244
|
+
assert_eq "0" "$(cat "$work/exit.txt")" "no hasher: hook still exits 0"
|
|
245
|
+
assert_eq "" "$(ls -A "$work/state" 2>/dev/null)" "no hasher: no shared state file is written"
|
|
246
|
+
assert_contains "$(cat "$work/stderr.txt")" "no usable hash tool" \
|
|
247
|
+
"no hasher: stderr diagnostic explains the skip"
|
|
248
|
+
rm -rf "$work"
|
|
249
|
+
|
|
250
|
+
# ── Bounded retry (security review Must Fix) ────────────────────────────────
|
|
251
|
+
# Leaving the watermark in place on failure removed the only bound on retry:
|
|
252
|
+
# LINES_SINCE_SAVE stays >=100 forever, so every turn re-ran the whole
|
|
253
|
+
# extraction and another non-idempotent create. The retry is now capped.
|
|
254
|
+
|
|
255
|
+
# 6. Five consecutive retryable failures (exit 5): work stops after the cap.
|
|
256
|
+
work=$(mktemp -d)
|
|
257
|
+
make_stub_bin "$work/bin"
|
|
258
|
+
make_transcript "$work/transcript.jsonl"
|
|
259
|
+
mkdir -p "$work/state" "$work/home"
|
|
260
|
+
# Per-run observations, indexed by run number. Arrays rather than eval'd
|
|
261
|
+
# variable names so the assignment is traceable (by shellcheck and by a reader).
|
|
262
|
+
declare -a watermark_after=() calls_after=() stderr_after=()
|
|
263
|
+
for run in 1 2 3 4 5; do
|
|
264
|
+
MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
265
|
+
watermark_after[run]="$(state_file_contents "$work")"
|
|
266
|
+
calls_after[run]="$(count_mx_calls "$work")"
|
|
267
|
+
stderr_after[run]="$(cat "$work/stderr.txt")"
|
|
268
|
+
done
|
|
269
|
+
assert_eq "" "${watermark_after[1]}" "retryable failure 1/3: watermark not advanced"
|
|
270
|
+
assert_eq "" "${watermark_after[2]}" "retryable failure 2/3: watermark not advanced"
|
|
271
|
+
assert_eq "300" "${watermark_after[3]}" "retryable failure 3/3: watermark advanced (cap reached)"
|
|
272
|
+
assert_contains "${stderr_after[2]}" "retry 2/3 next trigger" "retry counter is reported on stderr"
|
|
273
|
+
assert_contains "${stderr_after[3]}" "giving up" "cap is announced on stderr"
|
|
274
|
+
assert_eq "3" "${calls_after[3]}" "3 create attempts by the cap"
|
|
275
|
+
assert_eq "3" "${calls_after[5]}" "runs 4 and 5 attempt no create at all"
|
|
276
|
+
assert_eq "" "$(fail_count_contents "$work")" "fail counter is cleared once the segment is dropped"
|
|
277
|
+
rm -rf "$work"
|
|
278
|
+
|
|
279
|
+
# 7. Terminal statuses are never retried — the watermark advances immediately.
|
|
280
|
+
# 2 = validation (the request is malformed), 3 = auth, 124 = timeout (the
|
|
281
|
+
# server may already have persisted the memory; a retry would duplicate it,
|
|
282
|
+
# and the create is non-idempotent).
|
|
283
|
+
for terminal in 2 3 124; do
|
|
284
|
+
work=$(mktemp -d)
|
|
285
|
+
make_stub_bin "$work/bin"
|
|
286
|
+
make_transcript "$work/transcript.jsonl"
|
|
287
|
+
mkdir -p "$work/state" "$work/home"
|
|
288
|
+
MX_STUB_EXIT="$terminal" run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
289
|
+
assert_eq "300" "$(state_file_contents "$work")" \
|
|
290
|
+
"terminal exit ${terminal}: watermark advances on the first failure"
|
|
291
|
+
assert_eq "" "$(fail_count_contents "$work")" "terminal exit ${terminal}: no fail counter written"
|
|
292
|
+
assert_contains "$(cat "$work/stderr.txt")" "not retryable" \
|
|
293
|
+
"terminal exit ${terminal}: stderr names it as not retryable"
|
|
294
|
+
assert_contains "$(cat "$work/stderr.txt")" "exit ${terminal}" \
|
|
295
|
+
"terminal exit ${terminal}: stderr names the exit code"
|
|
296
|
+
rm -rf "$work"
|
|
297
|
+
done
|
|
298
|
+
|
|
299
|
+
# 8. A success after two failures clears the counter, so the next failure
|
|
300
|
+
# streak starts from 1 rather than tripping the cap immediately.
|
|
301
|
+
work=$(mktemp -d)
|
|
302
|
+
make_stub_bin "$work/bin"
|
|
303
|
+
make_transcript "$work/transcript.jsonl"
|
|
304
|
+
mkdir -p "$work/state" "$work/home"
|
|
305
|
+
MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
306
|
+
MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
307
|
+
assert_eq "2" "$(fail_count_contents "$work")" "two failures: counter is at 2"
|
|
308
|
+
MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
309
|
+
assert_eq "300" "$(state_file_contents "$work")" "success after 2 failures: watermark advances"
|
|
310
|
+
assert_eq "" "$(fail_count_contents "$work")" "success after 2 failures: fail counter cleared"
|
|
311
|
+
rm -rf "$work"
|
|
312
|
+
|
|
313
|
+
# 8b. The counter is the only bound on the retry. If it cannot be persisted —
|
|
314
|
+
# the path exists but is not writable — the count silently resets to 0 every
|
|
315
|
+
# trigger and the cap never trips. Inability to bound must be terminal.
|
|
316
|
+
work=$(mktemp -d)
|
|
317
|
+
make_stub_bin "$work/bin"
|
|
318
|
+
make_transcript "$work/transcript.jsonl"
|
|
319
|
+
mkdir -p "$work/state" "$work/home"
|
|
320
|
+
# Occupy the counter path with a DIRECTORY so the write can never succeed.
|
|
321
|
+
project_hash=$(printf '%s' "$work" | sha256sum | awk '{print $1}')
|
|
322
|
+
mkdir -p "$work/state/.mx-session-capture-${project_hash}.fails"
|
|
323
|
+
MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
324
|
+
assert_eq "300" "$(state_file_contents "$work")" \
|
|
325
|
+
"unwritable counter: watermark advances on the FIRST failure"
|
|
326
|
+
assert_contains "$(cat "$work/stderr.txt")" "cannot persist the retry counter" \
|
|
327
|
+
"unwritable counter: stderr names the reason"
|
|
328
|
+
calls_after_run1=$(count_mx_calls "$work")
|
|
329
|
+
MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
|
|
330
|
+
assert_eq "1" "$calls_after_run1" "unwritable counter: run 1 attempted exactly one create"
|
|
331
|
+
assert_eq "1" "$(count_mx_calls "$work")" \
|
|
332
|
+
"unwritable counter: run 2 attempts no create"
|
|
333
|
+
rm -rf "$work"
|
|
334
|
+
|
|
335
|
+
echo "── capture-precompact.sh ──"
|
|
336
|
+
|
|
337
|
+
# 6. Failed create → stderr diagnostic, still exit 0. (This hook has no
|
|
338
|
+
# watermark; none was invented.)
|
|
339
|
+
work2=$(mktemp -d)
|
|
340
|
+
make_stub_bin "$work2/bin"
|
|
341
|
+
make_transcript "$work2/transcript.jsonl"
|
|
342
|
+
mkdir -p "$work2/home"
|
|
343
|
+
( cd "$work2" && PATH="$work2/bin:$REAL_PATH" HOME="$work2/home" MX_STUB_EXIT=1 \
|
|
344
|
+
bash "$PRECOMPACT_HOOK" \
|
|
345
|
+
<<<"{\"transcript_path\":\"$work2/transcript.jsonl\",\"session_id\":\"s1\",\"trigger\":\"auto\"}" \
|
|
346
|
+
2>"$work2/stderr.txt" >/dev/null )
|
|
347
|
+
status2=$?
|
|
348
|
+
assert_eq "0" "$status2" "precompact: exits 0 when create fails"
|
|
349
|
+
assert_contains "$(cat "$work2/stderr.txt")" "mx memories create failed" \
|
|
350
|
+
"precompact: failed create writes a stderr diagnostic"
|
|
351
|
+
rm -rf "$work2"
|
|
352
|
+
|
|
353
|
+
echo "── dev-tools/claude-hooks/auto-save-memory.sh ──"
|
|
354
|
+
|
|
355
|
+
run_autosave() { # <workdir> ; uses MX_STUB_EXIT
|
|
356
|
+
local work="$1"
|
|
357
|
+
( cd "$work" && PATH="$work/bin:$REAL_PATH" MX_AUTOSAVE_STATE_FILE="$work/state-line" \
|
|
358
|
+
MX_STUB_LOG="$work/mx-calls.log" \
|
|
359
|
+
bash "$AUTOSAVE_HOOK" \
|
|
360
|
+
<<<"{\"transcript_path\":\"$work/transcript.jsonl\",\"stop_hook_active\":false}" \
|
|
361
|
+
2>"$work/stderr.txt" >/dev/null )
|
|
362
|
+
echo "$?" >"$work/exit.txt"
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# 7. Failed create → the line-number watermark is not recorded, so the turn is
|
|
366
|
+
# retried instead of being skipped as "already saved".
|
|
367
|
+
work=$(mktemp -d)
|
|
368
|
+
make_stub_bin "$work/bin"
|
|
369
|
+
make_transcript "$work/transcript.jsonl"
|
|
370
|
+
MX_STUB_EXIT=1 run_autosave "$work"
|
|
371
|
+
assert_eq "0" "$(cat "$work/exit.txt")" "auto-save: exits 0 when create fails"
|
|
372
|
+
if [[ -f "$work/state-line" ]]; then
|
|
373
|
+
nope "auto-save: failed create leaves the watermark unwritten" \
|
|
374
|
+
"state-line holds: $(cat "$work/state-line")"
|
|
375
|
+
else
|
|
376
|
+
ok "auto-save: failed create leaves the watermark unwritten"
|
|
377
|
+
fi
|
|
378
|
+
assert_contains "$(cat "$work/stderr.txt")" "mx memories create failed" \
|
|
379
|
+
"auto-save: failed create writes a stderr diagnostic"
|
|
380
|
+
rm -rf "$work"
|
|
381
|
+
|
|
382
|
+
# 8. Successful create → the watermark is recorded.
|
|
383
|
+
work=$(mktemp -d)
|
|
384
|
+
make_stub_bin "$work/bin"
|
|
385
|
+
make_transcript "$work/transcript.jsonl"
|
|
386
|
+
MX_STUB_EXIT=0 run_autosave "$work"
|
|
387
|
+
assert_eq "0" "$(cat "$work/exit.txt")" "auto-save: exits 0 on success"
|
|
388
|
+
if [[ -s "$work/state-line" ]]; then
|
|
389
|
+
ok "auto-save: successful create records the watermark"
|
|
390
|
+
else
|
|
391
|
+
nope "auto-save: successful create records the watermark" "state-line missing or empty"
|
|
392
|
+
fi
|
|
393
|
+
rm -rf "$work"
|
|
394
|
+
|
|
395
|
+
# 9. auto-save: the same bounded retry. Five consecutive retryable failures
|
|
396
|
+
# stop after the cap; terminal statuses advance immediately.
|
|
397
|
+
work=$(mktemp -d)
|
|
398
|
+
make_stub_bin "$work/bin"
|
|
399
|
+
make_transcript "$work/transcript.jsonl"
|
|
400
|
+
for run in 1 2 3 4 5; do
|
|
401
|
+
MX_STUB_EXIT=5 run_autosave "$work"
|
|
402
|
+
done
|
|
403
|
+
if [[ -s "$work/state-line" ]]; then
|
|
404
|
+
ok "auto-save: watermark advanced once the retry cap was reached"
|
|
405
|
+
else
|
|
406
|
+
nope "auto-save: watermark advanced once the retry cap was reached" "state-line missing"
|
|
407
|
+
fi
|
|
408
|
+
assert_eq "3" "$(count_mx_calls "$work")" \
|
|
409
|
+
"auto-save: 3 create attempts across 5 runs (runs 4-5 attempt none)"
|
|
410
|
+
if [[ -f "$work/state-line.fails" ]]; then
|
|
411
|
+
nope "auto-save: fail counter cleared after giving up" "counter still present"
|
|
412
|
+
else
|
|
413
|
+
ok "auto-save: fail counter cleared after giving up"
|
|
414
|
+
fi
|
|
415
|
+
rm -rf "$work"
|
|
416
|
+
|
|
417
|
+
work=$(mktemp -d)
|
|
418
|
+
make_stub_bin "$work/bin"
|
|
419
|
+
make_transcript "$work/transcript.jsonl"
|
|
420
|
+
MX_STUB_EXIT=124 run_autosave "$work"
|
|
421
|
+
if [[ -s "$work/state-line" ]]; then
|
|
422
|
+
ok "auto-save: timeout (124) is terminal — watermark advances, no duplicate-minting retry"
|
|
423
|
+
else
|
|
424
|
+
nope "auto-save: timeout (124) is terminal" "state-line missing"
|
|
425
|
+
fi
|
|
426
|
+
assert_contains "$(cat "$work/stderr.txt")" "not retryable" \
|
|
427
|
+
"auto-save: timeout diagnostic names it as not retryable"
|
|
428
|
+
rm -rf "$work"
|
|
429
|
+
|
|
430
|
+
# 9b. auto-save: same unwritable-counter handling.
|
|
431
|
+
work=$(mktemp -d)
|
|
432
|
+
make_stub_bin "$work/bin"
|
|
433
|
+
make_transcript "$work/transcript.jsonl"
|
|
434
|
+
mkdir -p "$work/state-line.fails"
|
|
435
|
+
MX_STUB_EXIT=5 run_autosave "$work"
|
|
436
|
+
if [[ -s "$work/state-line" ]]; then
|
|
437
|
+
ok "auto-save: unwritable counter advances the watermark on the first failure"
|
|
438
|
+
else
|
|
439
|
+
nope "auto-save: unwritable counter advances the watermark" "state-line missing"
|
|
440
|
+
fi
|
|
441
|
+
assert_contains "$(cat "$work/stderr.txt")" "cannot persist the retry counter" \
|
|
442
|
+
"auto-save: unwritable counter names the reason"
|
|
443
|
+
MX_STUB_EXIT=5 run_autosave "$work"
|
|
444
|
+
assert_eq "1" "$(count_mx_calls "$work")" \
|
|
445
|
+
"auto-save: unwritable counter — run 2 attempts no create"
|
|
446
|
+
rm -rf "$work"
|
|
447
|
+
|
|
448
|
+
# 10. precompact emits exactly one diagnostic per trigger (it has no watermark;
|
|
449
|
+
# one line per PreCompact event is inherent and bounded by the event).
|
|
450
|
+
work=$(mktemp -d)
|
|
451
|
+
make_stub_bin "$work/bin"
|
|
452
|
+
make_transcript "$work/transcript.jsonl"
|
|
453
|
+
mkdir -p "$work/home"
|
|
454
|
+
( cd "$work" && PATH="$work/bin:$REAL_PATH" HOME="$work/home" MX_STUB_EXIT=5 \
|
|
455
|
+
bash "$PRECOMPACT_HOOK" \
|
|
456
|
+
<<<"{\"transcript_path\":\"$work/transcript.jsonl\",\"session_id\":\"s1\",\"trigger\":\"auto\"}" \
|
|
457
|
+
2>"$work/stderr.txt" >/dev/null )
|
|
458
|
+
assert_eq "1" "$(grep -c 'mx memories create failed' "$work/stderr.txt")" \
|
|
459
|
+
"precompact: exactly one diagnostic per trigger"
|
|
460
|
+
rm -rf "$work"
|
|
461
|
+
|
|
462
|
+
echo ""
|
|
463
|
+
echo "capture-hooks.test.sh: $PASS passed, $FAIL failed"
|
|
464
|
+
[[ "$FAIL" -eq 0 ]] || exit 1
|
|
465
|
+
exit 0
|
|
@@ -209,10 +209,20 @@ elif command -v gtimeout >/dev/null 2>&1; then
|
|
|
209
209
|
TIMEOUT_CMD="gtimeout 10"
|
|
210
210
|
fi
|
|
211
211
|
|
|
212
|
+
# Capture the create's exit status instead of swallowing it with `|| true`: a
|
|
213
|
+
# failed save was completely invisible, and this hook has NO watermark, so the
|
|
214
|
+
# context was simply lost at compaction with no signal. Fail-open with Claude
|
|
215
|
+
# Code is unchanged — the hook still always exits 0 and never blocks
|
|
216
|
+
# compaction.
|
|
217
|
+
CREATE_STATUS=0
|
|
212
218
|
$TIMEOUT_CMD mx memories create \
|
|
213
219
|
--conversation-id "NEW" \
|
|
214
220
|
--content "$CONTENT" \
|
|
215
221
|
--topics "auto-captured,precompact" \
|
|
216
|
-
>/dev/null 2>&1 ||
|
|
222
|
+
>/dev/null 2>&1 || CREATE_STATUS=$?
|
|
223
|
+
|
|
224
|
+
if [[ "$CREATE_STATUS" -ne 0 ]]; then
|
|
225
|
+
echo "capture-precompact: mx memories create failed (exit ${CREATE_STATUS}) — pre-compaction context was NOT saved" >&2
|
|
226
|
+
fi
|
|
217
227
|
|
|
218
228
|
exit 0
|
|
@@ -55,13 +55,44 @@ if [[ -z "$PROJECT_DIR" || "$PROJECT_DIR" == "." ]]; then
|
|
|
55
55
|
fi
|
|
56
56
|
PROJECT_NAME=$(basename "$PROJECT_DIR" 2>/dev/null || echo "unknown")
|
|
57
57
|
|
|
58
|
-
# Hash the project directory path for a unique, collision-free state filename
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
# Hash the project directory path for a unique, collision-free state filename.
|
|
59
|
+
#
|
|
60
|
+
# The previous form piped each hasher into awk, so the `||` chain tested awk's
|
|
61
|
+
# exit status, not the hasher's. awk succeeds on empty input, so the very first
|
|
62
|
+
# branch always "succeeded" and the sha256sum/cksum fallbacks were dead code:
|
|
63
|
+
# on a machine without md5sum, PROJECT_HASH came out EMPTY and every project
|
|
64
|
+
# shared one state file "…-capture-". Test the hasher's own availability.
|
|
65
|
+
#
|
|
66
|
+
# cksum is NOT in the chain: it is a 32-bit CRC, so a colliding project path is
|
|
67
|
+
# constructible. A collision means two projects share a state file and one
|
|
68
|
+
# project's watermark suppresses the other's capture. Ordered strongest first.
|
|
69
|
+
hash_project_dir() {
|
|
70
|
+
local tool
|
|
71
|
+
for tool in sha256sum md5sum shasum b2sum; do
|
|
72
|
+
if command -v "$tool" >/dev/null 2>&1; then
|
|
73
|
+
printf '%s' "$PROJECT_DIR" | "$tool" 2>/dev/null | awk '{print $1}'
|
|
74
|
+
return 0
|
|
75
|
+
fi
|
|
76
|
+
done
|
|
77
|
+
# No hasher available. Return success with empty output so the ERR trap does
|
|
78
|
+
# not fire before the diagnostic below can print.
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
PROJECT_HASH=$(hash_project_dir)
|
|
82
|
+
|
|
83
|
+
# An empty hash must never fall through to a shared state file: one project's
|
|
84
|
+
# watermark would suppress every other project's capture.
|
|
85
|
+
if [[ -z "$PROJECT_HASH" ]]; then
|
|
86
|
+
echo "capture-session-end: no usable hash tool (sha256sum/md5sum/shasum/b2sum) — skipping capture" >&2
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
62
90
|
STATE_DIR="${XDG_RUNTIME_DIR:-${HOME}/.memnexus/state}"
|
|
63
91
|
mkdir -p "$STATE_DIR" 2>/dev/null
|
|
64
92
|
STATE_FILE="${STATE_DIR}/.mx-session-capture-${PROJECT_HASH}"
|
|
93
|
+
# Consecutive-failure counter for the bounded retry in section 8.
|
|
94
|
+
FAIL_FILE="${STATE_FILE}.fails"
|
|
95
|
+
MAX_CAPTURE_ATTEMPTS=3
|
|
65
96
|
|
|
66
97
|
# ── 4. Debounce — only save when significant new work has accumulated ─────
|
|
67
98
|
CURRENT_LINES=$(wc -l < "$TRANSCRIPT_PATH" 2>/dev/null || echo "0")
|
|
@@ -110,6 +141,7 @@ TOOL_COUNT=$(echo "$SEGMENT" | jq -r '
|
|
|
110
141
|
# Skip if segment was trivial — update state so we don't recheck same lines
|
|
111
142
|
if [[ "$TOOL_COUNT" -lt 5 && -z "$FILES_MODIFIED" ]]; then
|
|
112
143
|
echo "$CURRENT_LINES" > "$STATE_FILE" 2>/dev/null || true
|
|
144
|
+
rm -f "$FAIL_FILE" 2>/dev/null || true
|
|
113
145
|
exit 0
|
|
114
146
|
fi
|
|
115
147
|
|
|
@@ -213,13 +245,75 @@ elif command -v gtimeout >/dev/null 2>&1; then
|
|
|
213
245
|
TIMEOUT_CMD="gtimeout 10"
|
|
214
246
|
fi
|
|
215
247
|
|
|
248
|
+
# Capture the create's exit status. The `|| true` swallowed it, and the
|
|
249
|
+
# watermark below advanced regardless — so a failed save silently discarded the
|
|
250
|
+
# segment forever. Fail-open with Claude Code is unchanged: this hook still
|
|
251
|
+
# always exits 0 and never blocks the session.
|
|
252
|
+
CREATE_STATUS=0
|
|
216
253
|
$TIMEOUT_CMD mx memories create \
|
|
217
254
|
--conversation-id "NEW" \
|
|
218
255
|
--content "$CONTENT" \
|
|
219
256
|
--topics "auto-captured,session-summary" \
|
|
220
|
-
>/dev/null 2>&1 ||
|
|
257
|
+
>/dev/null 2>&1 || CREATE_STATUS=$?
|
|
258
|
+
|
|
259
|
+
# ── 8. Update debounce state (bounded retry) ─────────────────────────────
|
|
260
|
+
# Advance the watermark ONLY on a successful save, so a failed save is retried
|
|
261
|
+
# rather than silently discarded — but BOUND that retry. Leaving the watermark
|
|
262
|
+
# in place keeps LINES_SINCE_SAVE >= 100 forever, so on a persistent failure
|
|
263
|
+
# every subsequent turn re-runs tail + six jq passes over an ever-growing
|
|
264
|
+
# segment and another 10s-timeout create. The create is non-idempotent
|
|
265
|
+
# (--conversation-id NEW), so a timeout against a slow-but-succeeding API would
|
|
266
|
+
# mint one duplicate memory per turn.
|
|
267
|
+
#
|
|
268
|
+
# Terminal statuses are never retried:
|
|
269
|
+
# 2 validation — the request is malformed; retrying re-sends it unchanged
|
|
270
|
+
# 3 auth — no credential change happens between turns
|
|
271
|
+
# 124 timeout — the server MAY have persisted the memory; a retry would
|
|
272
|
+
# duplicate it
|
|
273
|
+
# Retryable statuses (4 network, 5 server, anything else) get up to
|
|
274
|
+
# MAX_CAPTURE_ATTEMPTS consecutive tries, then the segment is dropped.
|
|
275
|
+
#
|
|
276
|
+
# Fail-open with Claude Code is unchanged: this hook still always exits 0.
|
|
277
|
+
read_fail_count() {
|
|
278
|
+
local n=0
|
|
279
|
+
if [[ -f "$FAIL_FILE" ]]; then
|
|
280
|
+
n=$(cat "$FAIL_FILE" 2>/dev/null || echo 0)
|
|
281
|
+
fi
|
|
282
|
+
[[ "$n" =~ ^[0-9]+$ ]] || n=0
|
|
283
|
+
echo "$n"
|
|
284
|
+
}
|
|
221
285
|
|
|
222
|
-
|
|
223
|
-
echo "$CURRENT_LINES" > "$STATE_FILE" 2>/dev/null || true
|
|
286
|
+
advance_watermark() {
|
|
287
|
+
echo "$CURRENT_LINES" > "$STATE_FILE" 2>/dev/null || true
|
|
288
|
+
rm -f "$FAIL_FILE" 2>/dev/null || true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if [[ "$CREATE_STATUS" -eq 0 ]]; then
|
|
292
|
+
advance_watermark
|
|
293
|
+
else
|
|
294
|
+
case "$CREATE_STATUS" in
|
|
295
|
+
2 | 3 | 124)
|
|
296
|
+
advance_watermark
|
|
297
|
+
echo "capture-session-end: mx memories create failed (exit ${CREATE_STATUS}, not retryable) — segment dropped" >&2
|
|
298
|
+
;;
|
|
299
|
+
*)
|
|
300
|
+
ATTEMPTS=$(( $(read_fail_count) + 1 ))
|
|
301
|
+
if [[ "$ATTEMPTS" -ge "$MAX_CAPTURE_ATTEMPTS" ]]; then
|
|
302
|
+
advance_watermark
|
|
303
|
+
echo "capture-session-end: mx memories create failed (exit ${CREATE_STATUS}) ${ATTEMPTS} times — giving up, segment dropped" >&2
|
|
304
|
+
elif ! echo "$ATTEMPTS" > "$FAIL_FILE" 2>/dev/null; then
|
|
305
|
+
# The counter is the only thing bounding the retry. If it cannot be
|
|
306
|
+
# persisted (path exists but is not writable, read-only state dir), the
|
|
307
|
+
# count silently resets to 0 on every trigger and the cap never trips —
|
|
308
|
+
# an unbounded loop of non-idempotent creates. Inability to bound is
|
|
309
|
+
# treated as terminal.
|
|
310
|
+
advance_watermark
|
|
311
|
+
echo "capture-session-end: cannot persist the retry counter — segment dropped" >&2
|
|
312
|
+
else
|
|
313
|
+
echo "capture-session-end: mx memories create failed (exit ${CREATE_STATUS}) — segment not saved, retry ${ATTEMPTS}/${MAX_CAPTURE_ATTEMPTS} next trigger" >&2
|
|
314
|
+
fi
|
|
315
|
+
;;
|
|
316
|
+
esac
|
|
317
|
+
fi
|
|
224
318
|
|
|
225
319
|
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memnexus-ai/cli",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.222",
|
|
4
4
|
"description": "Command-line interface for MemNexus Core API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=18.0.0"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@memnexus-ai/typescript-sdk": "^1.
|
|
37
|
+
"@memnexus-ai/typescript-sdk": "^1.71.5",
|
|
38
38
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
39
39
|
"axios": "^1.13.6",
|
|
40
40
|
"chalk": "^5.3.0",
|