@mingxy/cerebro-claude-code 0.3.3

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.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: memory-search
3
+ description: Semantic search over the user's long-term memory (Cerebro). Use when the user references past work, prior decisions, stored preferences, or session history you may have saved; when an ambiguous name/concept could be disambiguated by stored context; or when you need background that earlier sessions likely captured. Searches are auto-filtered to the current project + user tags. Returns ranked matches with relevance scores.
4
+ ---
5
+
6
+ # Memory Search
7
+
8
+ Search long-term memory by semantic similarity. Memories are scoped to the current project automatically; global-scope memories are always included.
9
+
10
+ ## When to use
11
+
12
+ - User mentions "earlier / before / last time / we discussed / remember when …"
13
+ - A name, file, or concept feels ambiguous and stored context could resolve it
14
+ - You need a prior decision, rationale, or preference before acting
15
+ - Recovering context that was compacted away in a previous session
16
+
17
+ ## How to run
18
+
19
+ ```bash
20
+ bash "$CLAUDE_PLUGIN_ROOT/scripts/memory-search.sh" "auth flow refresh-token decision" 10
21
+ ```
22
+
23
+ - Arg 1: natural-language query (auto-truncated; quoting is recommended).
24
+ - Arg 2 (optional): result limit. Defaults to `$MEM_SEARCH_COUNT` (8).
25
+
26
+ Content can also be piped via stdin:
27
+
28
+ ```bash
29
+ echo "why did we pick sqlite over postgres" | bash "$CLAUDE_PLUGIN_ROOT/scripts/memory-search.sh" -
30
+ ```
31
+
32
+ ## Output format
33
+
34
+ One line per match:
35
+
36
+ ```
37
+ [0.91] mem_abc123: Fixed refresh-token rotation bug in auth.rs — added replay guard
38
+ [0.78] mem_def456: Decision: use sqlite for local cache, postgres only on cloud
39
+ ```
40
+
41
+ `no memories` when nothing matches. `error: …` on transport/server failure.
42
+
43
+ ## Notes
44
+
45
+ - Query is auto-truncated to `$MEM_MAX_QUERY_LENGTH` (200 chars).
46
+ - Filters by `omem_user_<hash>` and `omem_project_<hash>` tags plus `project_path`, so results respect project isolation.
47
+ - For full content of a truncated match, follow up with `memory-save` is NOT the right tool — fetch by id via the server API or re-run search with a sharper query.
@@ -0,0 +1,123 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ cleanText,
5
+ formatRelativeAge,
6
+ truncateQuery,
7
+ sanitizeContent,
8
+ } from "../hooks/common.mjs";
9
+
10
+ describe("cleanText", () => {
11
+ test("strips cerebro inject tags", () => {
12
+ const input = "hello <cerebro-memory>secret</cerebro-memory> world";
13
+ assert.equal(cleanText(input), "hello world");
14
+ });
15
+
16
+ test("strips cerebro self-closing tags", () => {
17
+ const input = "text <cerebro-nudge /> after";
18
+ assert.equal(cleanText(input), "text after");
19
+ });
20
+
21
+ test("strips system-reminder tags", () => {
22
+ const input = "before <system-reminder>internal</system-reminder> after";
23
+ assert.equal(cleanText(input), "before after");
24
+ });
25
+
26
+ test("strips supermemory tags", () => {
27
+ const input = "a <supermemory-context>data</supermemory-context> b";
28
+ assert.equal(cleanText(input), "a b");
29
+ });
30
+
31
+ test("collapses whitespace", () => {
32
+ const input = "line1\n\n\n line2 \n\tline3";
33
+ assert.equal(cleanText(input), "line1 line2 line3");
34
+ });
35
+
36
+ test("handles non-string input", () => {
37
+ assert.equal(cleanText(42), "42");
38
+ assert.equal(cleanText(null), "null");
39
+ });
40
+ });
41
+
42
+ describe("formatRelativeAge", () => {
43
+ test("returns 'unknown' for null/undefined", () => {
44
+ assert.equal(formatRelativeAge(null), "unknown");
45
+ assert.equal(formatRelativeAge(undefined), "unknown");
46
+ assert.equal(formatRelativeAge(""), "unknown");
47
+ });
48
+
49
+ test("returns minutes for recent dates", () => {
50
+ const fiveMinAgo = new Date(Date.now() - 5 * 60000).toISOString();
51
+ const result = formatRelativeAge(fiveMinAgo);
52
+ assert.match(result, /^\d+m ago$/);
53
+ });
54
+
55
+ test("returns hours for older dates", () => {
56
+ const threeHrAgo = new Date(Date.now() - 3 * 3600000).toISOString();
57
+ const result = formatRelativeAge(threeHrAgo);
58
+ assert.match(result, /^\d+h ago$/);
59
+ });
60
+
61
+ test("returns days for dates > 24h", () => {
62
+ const threeDayAgo = new Date(Date.now() - 3 * 86400000).toISOString();
63
+ const result = formatRelativeAge(threeDayAgo);
64
+ assert.match(result, /^\d+d ago$/);
65
+ });
66
+
67
+ test("returns months for dates > 30d", () => {
68
+ const twoMonthAgo = new Date(Date.now() - 70 * 86400000).toISOString();
69
+ const result = formatRelativeAge(twoMonthAgo);
70
+ assert.match(result, /^\d+mo ago$/);
71
+ });
72
+ });
73
+
74
+ describe("truncateQuery", () => {
75
+ test("returns empty for empty input", () => {
76
+ assert.equal(truncateQuery(""), "");
77
+ assert.equal(truncateQuery(null), "");
78
+ assert.equal(truncateQuery(undefined), "");
79
+ });
80
+
81
+ test("returns short query unchanged", () => {
82
+ assert.equal(truncateQuery("hello"), "hello");
83
+ });
84
+
85
+ test("truncates long query to default length", () => {
86
+ const long = "x".repeat(300);
87
+ const result = truncateQuery(long);
88
+ assert.equal(result.length, 200);
89
+ });
90
+
91
+ test("respects custom length", () => {
92
+ const long = "x".repeat(100);
93
+ assert.equal(truncateQuery(long, 50).length, 50);
94
+ });
95
+ });
96
+
97
+ describe("sanitizeContent", () => {
98
+ test("removes HTML tags", () => {
99
+ const input = "text <div>inner</div> more";
100
+ assert.equal(sanitizeContent(input, 1000), "text more");
101
+ });
102
+
103
+ test("removes self-closing tags", () => {
104
+ const input = "a <br/> b";
105
+ assert.equal(sanitizeContent(input, 1000), "a b");
106
+ });
107
+
108
+ test("collapses whitespace", () => {
109
+ const input = "line1\n\n line2";
110
+ assert.equal(sanitizeContent(input, 1000), "line1 line2");
111
+ });
112
+
113
+ test("truncates with ellipsis marker when exceeding maxLen", () => {
114
+ const long = "x".repeat(200);
115
+ const result = sanitizeContent(long, 50);
116
+ assert.ok(result.length <= 65); // 50 + "…[truncated]"
117
+ assert.ok(result.includes("…[truncated]"));
118
+ });
119
+
120
+ test("does not truncate short content", () => {
121
+ assert.equal(sanitizeContent("short", 1000), "short");
122
+ });
123
+ });
@@ -0,0 +1,111 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawn } from "node:child_process";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const HOOKS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks");
8
+
9
+ /**
10
+ * Run a hook script with given stdin JSON, return parsed stdout JSON.
11
+ * Points OMEM_API_URL to a dead port so fetch calls fail fast
12
+ * (postRecallEvent / buildMemoryInjection swallow errors via try/catch).
13
+ */
14
+ function runHook(script, stdinObj, opts = {}) {
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn(process.execPath, [join(HOOKS_DIR, script)], {
17
+ stdio: ["pipe", "pipe", "pipe"],
18
+ env: {
19
+ ...process.env,
20
+ OMEM_API_KEY: opts.apiKey ?? "test-key-12345",
21
+ OMEM_API_URL: opts.apiUrl ?? "http://127.0.0.1:1",
22
+ },
23
+ });
24
+ let stdout = "";
25
+ let stderr = "";
26
+ child.stdout.on("data", (d) => (stdout += d));
27
+ child.stderr.on("data", (d) => (stderr += d));
28
+ child.on("close", (code) => resolve({ stdout, stderr, code }));
29
+ child.on("error", reject);
30
+ child.stdin.write(JSON.stringify(stdinObj));
31
+ child.stdin.end();
32
+ });
33
+ }
34
+
35
+ describe("user-prompt-submit.mjs", () => {
36
+ test("injects cerebro-recall instruction for normal message", async () => {
37
+ const { stdout } = await runHook("user-prompt-submit.mjs", {
38
+ session_id: "test-sid",
39
+ prompt: "hello world",
40
+ });
41
+ const out = JSON.parse(stdout);
42
+ const ctx = out.hookSpecificOutput.additionalContext;
43
+ assert.ok(ctx.includes("<cerebro-recall>"), "should contain cerebro-recall instruction");
44
+ assert.ok(!ctx.includes("cerebro-nudge"), "should NOT contain nudge for normal message");
45
+ });
46
+
47
+ test("injects save nudge when prompt contains save keyword", async () => {
48
+ const { stdout } = await runHook("user-prompt-submit.mjs", {
49
+ session_id: "test-sid",
50
+ prompt: "记住这个配置",
51
+ });
52
+ const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
53
+ assert.ok(ctx.includes("<cerebro-nudge>"), "should contain nudge");
54
+ assert.ok(ctx.includes("memory-save"), "should mention memory-save skill");
55
+ });
56
+
57
+ test("injects recall nudge when prompt contains recall keyword", async () => {
58
+ const { stdout } = await runHook("user-prompt-submit.mjs", {
59
+ session_id: "test-sid",
60
+ prompt: "之前那个bug怎么修的",
61
+ });
62
+ const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
63
+ assert.ok(ctx.includes("<cerebro-nudge>"), "should contain nudge");
64
+ assert.ok(ctx.includes("memory-search"), "should mention memory-search skill");
65
+ });
66
+
67
+ test("handles English keywords", async () => {
68
+ const { stdout } = await runHook("user-prompt-submit.mjs", {
69
+ session_id: "test-sid",
70
+ prompt: "remember to update the docs",
71
+ });
72
+ const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
73
+ assert.ok(ctx.includes("cerebro-nudge"), "should detect English save keyword");
74
+ });
75
+ });
76
+
77
+ describe("pre-compact.mjs", () => {
78
+ test("outputs empty JSON (no hookSpecificOutput)", async () => {
79
+ const { stdout } = await runHook("pre-compact.mjs", {
80
+ session_id: "test-sid",
81
+ transcript_path: "/nonexistent/path.jsonl",
82
+ });
83
+ const out = JSON.parse(stdout);
84
+ assert.deepEqual(out, {});
85
+ });
86
+
87
+ test("outputs empty JSON when missing session_id", async () => {
88
+ const { stdout } = await runHook("pre-compact.mjs", {
89
+ transcript_path: "/nonexistent/path.jsonl",
90
+ });
91
+ const out = JSON.parse(stdout);
92
+ assert.deepEqual(out, {});
93
+ });
94
+ });
95
+
96
+ describe("session-end.mjs", () => {
97
+ test("outputs empty JSON for nonexistent transcript", async () => {
98
+ const { stdout } = await runHook("session-end.mjs", {
99
+ session_id: "test-sid",
100
+ transcript_path: "/nonexistent/path.jsonl",
101
+ });
102
+ const out = JSON.parse(stdout);
103
+ assert.deepEqual(out, {});
104
+ });
105
+
106
+ test("outputs empty JSON when missing fields", async () => {
107
+ const { stdout } = await runHook("session-end.mjs", {});
108
+ const out = JSON.parse(stdout);
109
+ assert.deepEqual(out, {});
110
+ });
111
+ });
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env bash
2
+ # cerebro Claude Code plugin — Phase5 smoke tests
3
+ #
4
+ # 零依赖:纯 bash + python3(不装 bats)。一键跑:bash tests/test_smoke.sh
5
+ #
6
+ # 覆盖:
7
+ # - session-start:无 key 分支([cerebro] 提示)+ 带 key 分支(stub server,
8
+ # 断言 [CEREBRO-MEMORY] / [CEREBRO-TIME] / <cerebro-profile> / recent)
9
+ # - user-prompt-submit:保存类 / 召回类 / 普通 / 英文 关键词 nudge
10
+ # - recall-approve:memory-search 放行 / 普通 Bash / 危险 Bash
11
+ # - memory-save / memory-search:参数校验 exit code + --help
12
+ # - stop / pre-compact:无 key 短路 {}
13
+ set -uo pipefail
14
+
15
+ PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
16
+ HOOKS="$PLUGIN_DIR/hooks"
17
+ SCRIPTS="$PLUGIN_DIR/scripts"
18
+ TMP="$(mktemp -d)"
19
+ STUB_PID=""
20
+
21
+ cleanup() {
22
+ [[ -n "$STUB_PID" ]] && kill "$STUB_PID" 2>/dev/null || true
23
+ rm -rf "$TMP" 2>/dev/null || true
24
+ }
25
+ trap cleanup EXIT
26
+
27
+ # ─── 环境隔离:不读真实 config,不带真实 key,不写日志 ────────────────────────
28
+ export CEREBRO_CONFIG_PATH="/nonexistent-test-cfg.json"
29
+ export OMEM_API_KEY=""
30
+ export OMEM_API_URL="http://127.0.0.1:1" # unreachable → curl fails silently
31
+ export MEM_LOG_ENABLED="0"
32
+ export MEM_LOG_DIR="$TMP/logs"
33
+
34
+ PASS=0
35
+ FAIL=0
36
+ FAILED_CASES=()
37
+
38
+ ok() { echo "PASS: $1"; PASS=$((PASS + 1)); }
39
+ fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); FAILED_CASES+=("$1"); }
40
+
41
+ assert_contains() { # <label> <haystack> <needle>
42
+ if [[ "$2" == *"$3"* ]]; then ok "$1"; else fail "$1 (missing: $3)"; fi
43
+ }
44
+ assert_not_contains() { # <label> <haystack> <needle>
45
+ if [[ "$2" != *"$3"* ]]; then ok "$1"; else fail "$1 (unexpected: $3)"; fi
46
+ }
47
+ assert_exit() { # <label> <expected> <actual>
48
+ if [[ "$2" == "$3" ]]; then ok "$1"; else fail "$1 (exit: want=$2 got=$3)"; fi
49
+ }
50
+ assert_eq() { # <label> <expected> <actual>
51
+ if [[ "$2" == "$3" ]]; then ok "$1"; else fail "$1 (want=[$2] got=[$3])"; fi
52
+ }
53
+
54
+ # ─── 本地 stub HTTP server(测 session-start 带 key 分支)─────────────────────
55
+ # 返回固定 profile + recent JSON,让 hook 走 [CEREBRO-MEMORY] 组装分支。
56
+ start_stub() {
57
+ cat > "$TMP/stub.py" <<'PYEOF'
58
+ import http.server, socketserver, json
59
+ class H(http.server.BaseHTTPRequestHandler):
60
+ def do_GET(self):
61
+ p = self.path
62
+ if p.startswith("/v2/profile/inject"):
63
+ body = json.dumps({"content": "TEST PROFILE", "preference_count": 1})
64
+ elif p.startswith("/v1/memories"):
65
+ body = json.dumps({"memories": [{"content": "recent activity line", "id": "m1"}]})
66
+ else:
67
+ body = "{}"
68
+ b = body.encode()
69
+ self.send_response(200)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", str(len(b)))
72
+ self.end_headers()
73
+ self.wfile.write(b)
74
+ def log_message(self, *a): pass
75
+ class S(socketserver.ThreadingMixIn, http.server.HTTPServer):
76
+ daemon_threads = True
77
+ srv = S(("127.0.0.1", 0), H)
78
+ print(srv.server_address[1], flush=True)
79
+ srv.serve_forever()
80
+ PYEOF
81
+ python3 "$TMP/stub.py" > "$TMP/port" 2>/dev/null &
82
+ STUB_PID=$!
83
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
84
+ [[ -s "$TMP/port" ]] && break
85
+ sleep 0.1
86
+ done
87
+ STUB_PORT="$(cat "$TMP/port" 2>/dev/null || echo "")"
88
+ }
89
+
90
+ echo "=== SessionStart ==="
91
+
92
+ # (1) 无 key 分支
93
+ out="$(OMEM_API_KEY="" bash "$HOOKS/session-start.sh" </dev/null 2>/dev/null || true)"
94
+ assert_contains "session-start no-key: hookSpecificOutput" "$out" '"hookSpecificOutput"'
95
+ assert_contains "session-start no-key: [cerebro] prefix" "$out" '[cerebro]'
96
+ assert_contains "session-start no-key: mentions API key" "$out" 'OMEM_API_KEY'
97
+
98
+ # (2) 带 key 分支(stub server)——验证 [CEREBRO-MEMORY] / [CEREBRO-TIME] / profile / recent
99
+ start_stub
100
+ if [[ -z "$STUB_PORT" ]]; then
101
+ fail "session-start stub: server did not start"
102
+ else
103
+ out="$(OMEM_API_KEY="test-key" OMEM_API_URL="http://127.0.0.1:$STUB_PORT" \
104
+ bash "$HOOKS/session-start.sh" </dev/null 2>/dev/null || true)"
105
+ assert_contains "session-start stub: hookSpecificOutput" "$out" '"hookSpecificOutput"'
106
+ assert_contains "session-start stub: [CEREBRO-MEMORY] open" "$out" '[CEREBRO-MEMORY]'
107
+ assert_contains "session-start stub: [/CEREBRO-MEMORY] close" "$out" '[/CEREBRO-MEMORY]'
108
+ assert_contains "session-start stub: [CEREBRO-TIME] line" "$out" '[CEREBRO-TIME]'
109
+ assert_contains "session-start stub: time prefix text" "$out" '当前:'
110
+ assert_contains "session-start stub: <cerebro-profile>" "$out" '<cerebro-profile>'
111
+ assert_contains "session-start stub: profile content" "$out" 'TEST PROFILE'
112
+ assert_contains "session-start stub: recent activity" "$out" 'recent activity line'
113
+ fi
114
+
115
+ echo
116
+ echo "=== UserPromptSubmit (keyword nudge) ==="
117
+
118
+ run_ups() { printf '%s' "$1" | bash "$HOOKS/user-prompt-submit.sh" 2>/dev/null || true; }
119
+
120
+ # 保存类(中文)
121
+ out="$(run_ups '{"prompt":"请记住这个决定:以后用 rust 写后端"}')"
122
+ assert_contains "ups save-zh: nudge tag" "$out" '<cerebro-nudge>'
123
+ assert_contains "ups save-zh: memory-save" "$out" 'memory-save skill'
124
+ assert_contains "ups save-zh: recall instr" "$out" '<cerebro-recall>'
125
+
126
+ # 召回类(中文)
127
+ out="$(run_ups '{"prompt":"上次那个 bug 修好了吗"}')"
128
+ assert_contains "ups recall-zh: nudge tag" "$out" '<cerebro-nudge>'
129
+ assert_contains "ups recall-zh: memory-search" "$out" 'memory-search skill'
130
+
131
+ # 保存类(英文)
132
+ out="$(run_ups '{"prompt":"please remember this decision"}')"
133
+ assert_contains "ups save-en: nudge tag" "$out" '<cerebro-nudge>'
134
+ assert_contains "ups save-en: memory-save" "$out" 'memory-save skill'
135
+
136
+ # 召回类(英文)
137
+ out="$(run_ups '{"prompt":"lets continue from earlier"}')"
138
+ assert_contains "ups recall-en: nudge tag" "$out" '<cerebro-nudge>'
139
+ assert_contains "ups recall-en: memory-search" "$out" 'memory-search skill'
140
+
141
+ # 普通消息:无 nudge,只有 recall 指令
142
+ out="$(run_ups '{"prompt":"你好,今天天气如何"}')"
143
+ assert_not_contains "ups plain: no nudge" "$out" '<cerebro-nudge>'
144
+ assert_contains "ups plain: recall instr" "$out" '<cerebro-recall>'
145
+
146
+ # 空/缺字段 stdin:不崩,有 recall 指令
147
+ out="$(run_ups '{}')"
148
+ assert_contains "ups empty: recall instr" "$out" '<cerebro-recall>'
149
+ assert_not_contains "ups empty: no nudge" "$out" '<cerebro-nudge>'
150
+
151
+ echo
152
+ echo "=== recall-approve (PreToolUse) ==="
153
+
154
+ run_ra() { printf '%s' "$1" | bash "$HOOKS/recall-approve.sh" 2>/dev/null || true; }
155
+
156
+ # memory-search Skill → allow
157
+ out="$(run_ra '{"tool_name":"Skill","tool_input":{"name":"memory-search"}}')"
158
+ assert_contains "ra skill-search: allow" "$out" '"permissionDecision"'
159
+ assert_contains "ra skill-search: allow val" "$out" 'allow'
160
+
161
+ # tool_name 直接含 memory-search → allow
162
+ out="$(run_ra '{"tool_name":"memory-search","tool_input":{}}')"
163
+ assert_contains "ra direct-search: allow" "$out" 'allow'
164
+
165
+ # 普通 Bash(非 memory-search)→ {}
166
+ out="$(run_ra '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}')"
167
+ assert_eq "ra plain-bash: deny {}" '{}' "$out"
168
+
169
+ # 危险 Bash(memory-search.sh; rm)→ {}
170
+ out="$(run_ra '{"tool_name":"Bash","tool_input":{"command":"bash memory-search.sh; rm -rf /"}}')"
171
+ assert_eq "ra danger-bash: deny {}" '{}' "$out"
172
+
173
+ # 危险 Bash($( 命令替换)→ {}
174
+ out="$(run_ra '{"tool_name":"Bash","tool_input":{"command":"memory-search.sh $(whoami)"}}')"
175
+ assert_eq "ra subst-bash: deny {}" '{}' "$out"
176
+
177
+ echo
178
+ echo "=== memory-save arg validation ==="
179
+
180
+ # 空 content → exit 1
181
+ bash "$SCRIPTS/memory-save.sh" </dev/null >/dev/null 2>&1; rc=$?
182
+ assert_exit "save empty-content: exit 1" "1" "$rc"
183
+
184
+ # bad visibility → exit 2
185
+ bash "$SCRIPTS/memory-save.sh" --content "x" --visibility bad >/dev/null 2>&1; rc=$?
186
+ assert_exit "save bad-visibility: exit 2" "2" "$rc"
187
+
188
+ # bad category → exit 2
189
+ bash "$SCRIPTS/memory-save.sh" --content "x" --category bad >/dev/null 2>&1; rc=$?
190
+ assert_exit "save bad-category: exit 2" "2" "$rc"
191
+
192
+ # bad scope → exit 2
193
+ bash "$SCRIPTS/memory-save.sh" --content "x" --scope bad >/dev/null 2>&1; rc=$?
194
+ assert_exit "save bad-scope: exit 2" "2" "$rc"
195
+
196
+ # unknown option → exit 2
197
+ bash "$SCRIPTS/memory-save.sh" --bogus >/dev/null 2>&1; rc=$?
198
+ assert_exit "save unknown-opt: exit 2" "2" "$rc"
199
+
200
+ # --help → exit 0 + usage text
201
+ help_out="$(bash "$SCRIPTS/memory-save.sh" --help 2>&1 || true)"; rc=$?
202
+ assert_exit "save --help: exit 0" "0" "$rc"
203
+ assert_contains "save --help: usage text" "$help_out" 'content'
204
+
205
+ echo
206
+ echo "=== memory-search arg validation ==="
207
+
208
+ # 空 query → exit 1
209
+ bash "$SCRIPTS/memory-search.sh" "" </dev/null >/dev/null 2>&1; rc=$?
210
+ assert_exit "search empty-query: exit 1" "1" "$rc"
211
+
212
+ echo
213
+ echo "=== stop / pre-compact (no-key short-circuit) ==="
214
+
215
+ # stop 无 key → {}
216
+ out="$(OMEM_API_KEY="" bash "$HOOKS/stop.sh" </dev/null 2>/dev/null || true)"
217
+ assert_eq "stop no-key: {}" '{}' "$out"
218
+
219
+ # pre-compact 无 key → {}
220
+ out="$(OMEM_API_KEY="" bash "$HOOKS/pre-compact.sh" </dev/null 2>/dev/null || true)"
221
+ assert_eq "pre-compact no-key: {}" '{}' "$out"
222
+
223
+ echo
224
+ echo "========================"
225
+ TOTAL=$((PASS + FAIL))
226
+ echo "Total: $TOTAL | PASS: $PASS | FAIL: $FAIL"
227
+ if [[ "$FAIL" -ne 0 ]]; then
228
+ echo "Failed cases:"
229
+ for c in "${FAILED_CASES[@]}"; do echo " - $c"; done
230
+ exit 1
231
+ fi
232
+ echo "ALL PASS"
233
+ exit 0