@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,646 @@
1
+ #!/usr/bin/env bash
2
+ # cerebro Claude Code plugin — shared HTTP + utility base (Phase1)
3
+ #
4
+ # Backward-compat guarantees (do NOT break existing hooks):
5
+ # - env vars: OMEM_API_URL, OMEM_API_KEY
6
+ # - functions: omem_get, omem_post, read_stdin (signatures unchanged)
7
+ # Phase1 adds: load_cerebro_config, omem_put/patch/delete, omem_health,
8
+ # detect_project_path, container_tags, sanitize_content, truncate_query,
9
+ # read_hook_input, log_*.
10
+ #
11
+ # Config cascade (mirrors plugins/opencode/src/config.ts — env wins):
12
+ # builtin default < ~/.config/cerebro/config.json < env var
13
+ set -euo pipefail
14
+
15
+ # ─── Builtin defaults ────────────────────────────────────────────────────────
16
+ # Note: RECENT/SEARCH defaults follow 师尊实战偏好, NOT opencode DEFAULTS (5/10).
17
+ # They only apply when neither config.json nor env provides a value.
18
+ _DEF_API_URL="https://www.mengxy.cc"
19
+ _DEF_REQUEST_TIMEOUT="15" # seconds (curl --max-time)
20
+ _DEF_RECENT_COUNT="8"
21
+ _DEF_SEARCH_COUNT="8"
22
+ _DEF_MAX_CONTENT="3000"
23
+ _DEF_MAX_QUERY_LENGTH="200"
24
+ _DEF_LOG_DIR="$HOME/.config/cerebro/logs"
25
+ _DEF_LOG_ENABLED="1"
26
+
27
+ # Pre-declare cascade targets so `set -u` is happy before bootstrap runs.
28
+ OMEM_API_URL="${OMEM_API_URL:-}"
29
+ OMEM_API_KEY="${OMEM_API_KEY:-}"
30
+ MEM_REQUEST_TIMEOUT="${MEM_REQUEST_TIMEOUT:-}"
31
+ MEM_RECENT_COUNT="${MEM_RECENT_COUNT:-}"
32
+ MEM_SEARCH_COUNT="${MEM_SEARCH_COUNT:-}"
33
+ MEM_MAX_CONTENT="${MEM_MAX_CONTENT:-}"
34
+ MEM_MAX_QUERY_LENGTH="${MEM_MAX_QUERY_LENGTH:-}"
35
+ MEM_LOG_DIR="${MEM_LOG_DIR:-}"
36
+ MEM_LOG_ENABLED="${MEM_LOG_ENABLED:-}"
37
+
38
+ # ─── Load shared cerebro config (single source of truth, same as opencode) ───
39
+ # Reads $CEREBRO_CONFIG_PATH, else $HOME/.config/cerebro/config.json.
40
+ # Populates _CFG_* vars. Silent on missing/parse-error (falls back to defaults).
41
+ # Ported from plugins/opencode/src/config.ts loadConfig().
42
+ load_cerebro_config() {
43
+ local cfg_path="${CEREBRO_CONFIG_PATH:-$HOME/.config/cerebro/config.json}"
44
+ [[ -z "$cfg_path" || ! -f "$cfg_path" ]] && { log_debug "load_cerebro_config: no config at $cfg_path"; return 0; }
45
+ local line key val
46
+ while IFS=$'\t' read -r key val; do
47
+ [[ -z "$key" ]] && continue
48
+ case "$key" in
49
+ apiUrl) _CFG_apiUrl="$val" ;;
50
+ apiKey) _CFG_apiKey="$val" ;;
51
+ requestTimeoutMs) _CFG_requestTimeoutMs="$val" ;;
52
+ maxQueryLength) _CFG_maxQueryLength="$val" ;;
53
+ maxContentChars) _CFG_maxContentChars="$val" ;;
54
+ maxContentLength) _CFG_maxContentLength="$val" ;;
55
+ recentCount) _CFG_recentCount="$val" ;;
56
+ searchCount) _CFG_searchCount="$val" ;;
57
+ recentTruncateChars) _CFG_recentTruncateChars="$val" ;;
58
+ searchTruncateChars) _CFG_searchTruncateChars="$val" ;;
59
+ recentTimeoutMs) _CFG_recentTimeoutMs="$val" ;;
60
+ searchTimeoutMs) _CFG_searchTimeoutMs="$val" ;;
61
+ profileTimeoutMs) _CFG_profileTimeoutMs="$val" ;;
62
+ autoCaptureThreshold) _CFG_autoCaptureThreshold="$val" ;;
63
+ ingestMode) _CFG_ingestMode="$val" ;;
64
+ logEnabled) _CFG_logEnabled="$val" ;;
65
+ logLevel) _CFG_logLevel="$val" ;;
66
+ logDir) _CFG_logDir="$val" ;;
67
+ esac
68
+ done < <(CEREBRO_CFG_PATH="$cfg_path" python3 -c '
69
+ import json, os, sys
70
+ path = os.environ["CEREBRO_CFG_PATH"]
71
+ try:
72
+ with open(path, "r") as f:
73
+ raw = json.load(f)
74
+ except Exception:
75
+ sys.exit(0)
76
+ if not isinstance(raw, dict):
77
+ sys.exit(0)
78
+ # Flat-config migration (legacy pre-nesting shape)
79
+ if "apiUrl" in raw and "connection" not in raw:
80
+ flat = raw
81
+ raw = {"connection": {k: flat[k] for k in ("apiUrl","apiKey","requestTimeoutMs") if k in flat},
82
+ "content": {k: flat[k] for k in ("maxQueryLength","maxContentChars","maxContentLength") if k in flat},
83
+ "ingest": {k: flat[k] for k in ("autoCaptureThreshold","ingestMode") if k in flat},
84
+ "logging": {k: flat[k] for k in ("logEnabled","logLevel","logDir") if k in flat}}
85
+
86
+ def emit(key, sect, leaf):
87
+ cur = raw.get(sect)
88
+ if isinstance(cur, dict) and leaf in cur and cur[leaf] is not None:
89
+ v = cur[leaf]
90
+ if isinstance(v, bool):
91
+ v = "1" if v else "0"
92
+ print(f"{key}\t{v}")
93
+
94
+ emit("apiUrl","connection","apiUrl")
95
+ emit("apiKey","connection","apiKey")
96
+ emit("requestTimeoutMs","connection","requestTimeoutMs")
97
+ emit("maxQueryLength","content","maxQueryLength")
98
+ emit("maxContentChars","content","maxContentChars")
99
+ emit("maxContentLength","content","maxContentLength")
100
+ emit("recentCount","injection","recentCount")
101
+ emit("searchCount","injection","searchCount")
102
+ emit("recentTruncateChars","injection","recentTruncateChars")
103
+ emit("searchTruncateChars","injection","searchTruncateChars")
104
+ emit("recentTimeoutMs","injection","recentTimeoutMs")
105
+ emit("searchTimeoutMs","injection","searchTimeoutMs")
106
+ emit("profileTimeoutMs","injection","profileTimeoutMs")
107
+ emit("autoCaptureThreshold","ingest","autoCaptureThreshold")
108
+ emit("ingestMode","ingest","ingestMode")
109
+ emit("logEnabled","logging","logEnabled")
110
+ emit("logLevel","logging","logLevel")
111
+ emit("logDir","logging","logDir")
112
+ ' 2>/dev/null)
113
+ log_debug "load_cerebro_config: loaded from $cfg_path"
114
+ }
115
+
116
+ # ─── HTTP Functions (legacy signatures stable; new verbs added) ──────────────
117
+
118
+ # GET request to cerebro API.
119
+ # Usage: omem_get "/v1/memories?limit=20"
120
+ omem_get() {
121
+ local path="$1"
122
+ curl -sf --max-time 8 \
123
+ -H "X-API-Key: ${OMEM_API_KEY}" \
124
+ -H "Accept: application/json" \
125
+ "${OMEM_API_URL}${path}" 2>/dev/null || echo '{"error": "request failed"}'
126
+ }
127
+
128
+ # POST request to cerebro API.
129
+ # Usage: omem_post "/v1/memories" '{"content": "..."}'
130
+ omem_post() {
131
+ local path="$1"
132
+ local body="$2"
133
+ curl -sf --max-time 8 \
134
+ -X POST \
135
+ -H "X-API-Key: ${OMEM_API_KEY}" \
136
+ -H "Content-Type: application/json" \
137
+ -H "Accept: application/json" \
138
+ -d "${body}" \
139
+ "${OMEM_API_URL}${path}" 2>/dev/null || echo '{"error": "request failed"}'
140
+ }
141
+
142
+ # PUT request to cerebro API. Uses MEM_REQUEST_TIMEOUT.
143
+ # Usage: omem_put "/v1/memories/abc" '{"content": "..."}'
144
+ omem_put() {
145
+ local path="$1"
146
+ local body="$2"
147
+ curl -sf --max-time "${MEM_REQUEST_TIMEOUT}" \
148
+ -X PUT \
149
+ -H "X-API-Key: ${OMEM_API_KEY}" \
150
+ -H "Content-Type: application/json" \
151
+ -H "Accept: application/json" \
152
+ -d "${body}" \
153
+ "${OMEM_API_URL}${path}" 2>/dev/null || echo '{"error": "request failed"}'
154
+ }
155
+
156
+ # PATCH request to cerebro API. Uses MEM_REQUEST_TIMEOUT.
157
+ # Usage: omem_patch "/v1/memories/abc" '{"content": "..."}'
158
+ omem_patch() {
159
+ local path="$1"
160
+ local body="$2"
161
+ curl -sf --max-time "${MEM_REQUEST_TIMEOUT}" \
162
+ -X PATCH \
163
+ -H "X-API-Key: ${OMEM_API_KEY}" \
164
+ -H "Content-Type: application/json" \
165
+ -H "Accept: application/json" \
166
+ -d "${body}" \
167
+ "${OMEM_API_URL}${path}" 2>/dev/null || echo '{"error": "request failed"}'
168
+ }
169
+
170
+ # DELETE request to cerebro API. Uses MEM_REQUEST_TIMEOUT.
171
+ # Usage: omem_delete "/v1/memories/abc"
172
+ omem_delete() {
173
+ local path="$1"
174
+ curl -sf --max-time "${MEM_REQUEST_TIMEOUT}" \
175
+ -X DELETE \
176
+ -H "X-API-Key: ${OMEM_API_KEY}" \
177
+ -H "Accept: application/json" \
178
+ "${OMEM_API_URL}${path}" 2>/dev/null || echo '{"error": "request failed"}'
179
+ }
180
+
181
+ # Health probe via /v1/stats. Returns curl exit code (0 = healthy).
182
+ omem_health() {
183
+ curl -sf --max-time 5 \
184
+ -H "X-API-Key: ${OMEM_API_KEY}" \
185
+ -H "Accept: application/json" \
186
+ "${OMEM_API_URL}/v1/stats" >/dev/null 2>&1
187
+ }
188
+
189
+ # ─── Input Functions ─────────────────────────────────────────────────────────
190
+
191
+ # Read hook input JSON from stdin (legacy, kept stable).
192
+ # Claude Code pipes hook context as JSON to stdin.
193
+ read_stdin() {
194
+ local input=""
195
+ if [[ ! -t 0 ]]; then
196
+ input=$(cat)
197
+ fi
198
+ echo "${input:-"{}"}"
199
+ }
200
+
201
+ # Read hook input JSON from stdin with light schema validation.
202
+ # Warns (non-fatal) when transcript_path is absent so silent failures surface.
203
+ # Emits the raw JSON on stdout (same shape as read_stdin).
204
+ read_hook_input() {
205
+ local input=""
206
+ if [[ ! -t 0 ]]; then
207
+ input=$(cat)
208
+ fi
209
+ input="${input:-"{}"}"
210
+ local has_tp
211
+ has_tp=$(printf '%s' "$input" | python3 -c '
212
+ import sys, json
213
+ try:
214
+ data = json.load(sys.stdin)
215
+ print("1" if data.get("transcript_path") else "0")
216
+ except Exception:
217
+ print("0")
218
+ ' 2>/dev/null || printf '0')
219
+ if [[ "$has_tp" != "1" ]]; then
220
+ log_warn "read_hook_input: transcript_path missing in hook input"
221
+ fi
222
+ printf '%s\n' "$input"
223
+ }
224
+
225
+ # ─── Project / User Tagging (mirror plugins/opencode/src/tags.ts) ────────────
226
+
227
+ # Detect git toplevel; fall back to $PWD. Returns empty for home/root dirs
228
+ # so we do not tag globally-shared locations as a project.
229
+ detect_project_path() {
230
+ local p
231
+ p=$(git rev-parse --show-toplevel 2>/dev/null) || p="$PWD"
232
+ [[ -z "$p" || "$p" == "/" || "$p" == "$HOME" ]] && return 0
233
+ printf '%s\n' "$p"
234
+ }
235
+
236
+ # sha256(input)[:16] — prefers coreutils, falls back to python3.
237
+ _sha256_16() {
238
+ local input="$1"
239
+ if command -v sha256sum >/dev/null 2>&1; then
240
+ printf '%s' "$input" | sha256sum | cut -c1-16
241
+ elif command -v shasum >/dev/null 2>&1; then
242
+ printf '%s' "$input" | shasum -a 256 | cut -c1-16
243
+ else
244
+ printf '%s' "$input" | python3 -c 'import sys,hashlib;print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])'
245
+ fi
246
+ }
247
+
248
+ # Emit space-separated container tags: omem_user_<16> and/or omem_project_<16>.
249
+ # Mirrors plugins/opencode/src/tags.ts (getUserTag / getProjectTag).
250
+ # Email source: OMEM_USER_EMAIL env, else git config user.email.
251
+ container_tags() {
252
+ local email project_dir user_tag="" project_tag=""
253
+ email="${OMEM_USER_EMAIL:-$(git config user.email 2>/dev/null || true)}"
254
+ project_dir="$(detect_project_path)"
255
+ [[ -n "$email" ]] && user_tag="omem_user_$(_sha256_16 "$email")"
256
+ [[ -n "$project_dir" ]] && project_tag="omem_project_$(_sha256_16 "$project_dir")"
257
+ if [[ -n "$user_tag" && -n "$project_tag" ]]; then
258
+ printf '%s %s\n' "$user_tag" "$project_tag"
259
+ elif [[ -n "$user_tag" ]]; then
260
+ printf '%s\n' "$user_tag"
261
+ elif [[ -n "$project_tag" ]]; then
262
+ printf '%s\n' "$project_tag"
263
+ fi
264
+ }
265
+
266
+ # ─── Content Sanitization (mirror plugins/opencode/src/client.ts) ────────────
267
+
268
+ # Strip XML-like tag blocks + self-closing tags, collapse whitespace,
269
+ # truncate to $1 (default $MEM_MAX_CONTENT). Reads stdin, writes stdout.
270
+ # Port of client.ts sanitizeContent().
271
+ sanitize_content() {
272
+ local max_len="${1:-$MEM_MAX_CONTENT}"
273
+ MEM_SC_MAX_LEN="$max_len" python3 -c '
274
+ import os, re, sys
275
+ max_len = int(os.environ.get("MEM_SC_MAX_LEN", "3000"))
276
+ data = sys.stdin.read()
277
+ # Remove <tag ...>...</tag> blocks (non-greedy, multi-line)
278
+ clean = re.sub(r"<[\w-]+[^>]*>[\s\S]*?</[\w-]+>", "", data)
279
+ # Remove self-closing tags <tag .../>
280
+ clean = re.sub(r"<[\w-]+[^>]*/>", "", clean)
281
+ # Collapse whitespace
282
+ clean = re.sub(r"\s+", " ", clean).strip()
283
+ if len(clean) <= max_len:
284
+ print(clean)
285
+ else:
286
+ print(clean[:max_len] + "…[truncated]")
287
+ '
288
+ }
289
+
290
+ # Truncate a search query to len (default $MEM_MAX_QUERY_LENGTH).
291
+ # Port of client.ts truncateQuery() — no ellipsis, hard slice.
292
+ truncate_query() {
293
+ local text="${1:-}"
294
+ local len="${2:-$MEM_MAX_QUERY_LENGTH}"
295
+ [[ -z "$text" ]] && return 0
296
+ if [[ ${#text} -le "$len" ]]; then
297
+ printf '%s' "$text"
298
+ else
299
+ printf '%s' "${text:0:$len}"
300
+ fi
301
+ }
302
+
303
+ # ─── Logging ─────────────────────────────────────────────────────────────────
304
+
305
+ # Internal: append "<iso-ts> <LEVEL> <msg>" to $MEM_LOG_DIR/claude-code.log.
306
+ # Best-effort: never fails the caller (all errors swallowed).
307
+ _log() {
308
+ local level="$1"; shift
309
+ [[ "$MEM_LOG_ENABLED" != "1" ]] && return 0
310
+ local msg="$*"
311
+ local ts log_file
312
+ ts="$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" || ts="$(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null)" || ts="unknown"
313
+ log_file="${MEM_LOG_DIR}/claude-code.log"
314
+ {
315
+ [[ -d "$MEM_LOG_DIR" ]] || mkdir -p "$MEM_LOG_DIR" 2>/dev/null || true
316
+ printf '%s %s %s\n' "$ts" "$level" "$msg" >> "$log_file" 2>/dev/null
317
+ } || true
318
+ }
319
+ log_warn() { _log "WARN" "$*"; }
320
+ log_error() { _log "ERROR" "$*"; }
321
+ log_debug() { _log "DEBUG" "$*"; }
322
+
323
+ # ─── Bootstrap (runs at source time) ─────────────────────────────────────────
324
+ # Cascade priority: env > config.json > builtin default.
325
+ # (matches opencode/src/config.ts: "Env vars have highest priority")
326
+ load_cerebro_config # populates _CFG_* when ~/.config/cerebro/config.json exists
327
+
328
+ if [[ -n "${OMEM_API_URL:-}" ]]; then : ;
329
+ elif [[ -n "${_CFG_apiUrl:-}" ]]; then OMEM_API_URL="${_CFG_apiUrl}";
330
+ else OMEM_API_URL="${_DEF_API_URL}"; fi
331
+
332
+ if [[ -n "${OMEM_API_KEY:-}" ]]; then : ;
333
+ elif [[ -n "${_CFG_apiKey:-}" ]]; then OMEM_API_KEY="${_CFG_apiKey}"; fi
334
+ # (no builtin default for the key — stays empty when neither source provides it)
335
+
336
+ # requestTimeout: config stores ms, curl wants seconds.
337
+ if [[ -n "${MEM_REQUEST_TIMEOUT:-}" ]]; then : ;
338
+ elif [[ "${_CFG_requestTimeoutMs:-}" =~ ^[0-9]+$ ]]; then
339
+ MEM_REQUEST_TIMEOUT=$(( _CFG_requestTimeoutMs / 1000 ));
340
+ else MEM_REQUEST_TIMEOUT="${_DEF_REQUEST_TIMEOUT}"; fi
341
+
342
+ if [[ -n "${MEM_RECENT_COUNT:-}" ]]; then : ;
343
+ elif [[ -n "${_CFG_recentCount:-}" ]]; then MEM_RECENT_COUNT="${_CFG_recentCount}";
344
+ else MEM_RECENT_COUNT="${_DEF_RECENT_COUNT}"; fi
345
+
346
+ if [[ -n "${MEM_SEARCH_COUNT:-}" ]]; then : ;
347
+ elif [[ -n "${_CFG_searchCount:-}" ]]; then MEM_SEARCH_COUNT="${_CFG_searchCount}";
348
+ else MEM_SEARCH_COUNT="${_DEF_SEARCH_COUNT}"; fi
349
+
350
+ if [[ -n "${MEM_MAX_CONTENT:-}" ]]; then : ;
351
+ elif [[ -n "${_CFG_maxContentLength:-}" ]]; then MEM_MAX_CONTENT="${_CFG_maxContentLength}";
352
+ else MEM_MAX_CONTENT="${_DEF_MAX_CONTENT}"; fi
353
+
354
+ if [[ -n "${MEM_MAX_QUERY_LENGTH:-}" ]]; then : ;
355
+ elif [[ -n "${_CFG_maxQueryLength:-}" ]]; then MEM_MAX_QUERY_LENGTH="${_CFG_maxQueryLength}";
356
+ else MEM_MAX_QUERY_LENGTH="${_DEF_MAX_QUERY_LENGTH}"; fi
357
+
358
+ if [[ -n "${MEM_LOG_DIR:-}" ]]; then : ;
359
+ elif [[ -n "${_CFG_logDir:-}" ]]; then MEM_LOG_DIR="${_CFG_logDir}";
360
+ else MEM_LOG_DIR="${_DEF_LOG_DIR}"; fi
361
+
362
+ if [[ -n "${MEM_LOG_ENABLED:-}" ]]; then : ;
363
+ elif [[ -n "${_CFG_logEnabled:-}" ]]; then MEM_LOG_ENABLED="${_CFG_logEnabled}";
364
+ else MEM_LOG_ENABLED="${_DEF_LOG_ENABLED}"; fi
365
+
366
+ # Normalize: strip trailing slash from URL, expand leading ~ in logDir.
367
+ OMEM_API_URL="${OMEM_API_URL%/}"
368
+ MEM_LOG_DIR="${MEM_LOG_DIR/#\~/$HOME}"
369
+
370
+ # ─── Incremental Cursor (Stop/PreCompact dedup, mirrors supermemory tracker) ─
371
+ # Per-session tracker stores the uuid of the last ingested transcript entry.
372
+ # Next run only walks entries past that uuid → only the delta is POSTed.
373
+ # Tolerant: missing/corrupt file → empty (treat as first run, full sweep).
374
+ _CEREBRO_TRACKER_DIR="${HOME}/.config/cerebro/trackers"
375
+
376
+ # cursor_get <sessionId> → echoes last saved uuid (empty when none/missing).
377
+ cursor_get() {
378
+ local sid="${1:-}"
379
+ [[ -z "$sid" ]] && { printf ''; return 0; }
380
+ local f="${_CEREBRO_TRACKER_DIR}/${sid}.txt"
381
+ [[ -f "$f" ]] || { printf ''; return 0; }
382
+ local last
383
+ last=$(head -c 256 "$f" 2>/dev/null | tr -d '\r\n ') || { printf ''; return 0; }
384
+ printf '%s' "$last"
385
+ }
386
+
387
+ # cursor_set <sessionId> <lastId> — persist last id (best-effort, never fails).
388
+ cursor_set() {
389
+ local sid="${1:-}" last="${2:-}"
390
+ [[ -z "$sid" || -z "$last" ]] && return 0
391
+ { [[ -d "$_CEREBRO_TRACKER_DIR" ]] || mkdir -p "$_CEREBRO_TRACKER_DIR" 2>/dev/null || true; } || true
392
+ printf '%s\n' "$last" > "${_CEREBRO_TRACKER_DIR}/${sid}.txt" 2>/dev/null || true
393
+ }
394
+
395
+ # ─── Project name detection (mirrors opencode hooks.ts detectProjectName) ────
396
+ # Probe AGENTS.md marker is not a name source; derive from manifests:
397
+ # package.json / composer.json (name field), Cargo.toml / pyproject.toml
398
+ # (^name = "..."), go.mod (^module path → last segment). Fallback: dir basename.
399
+ # Output: sanitized to [A-Za-z0-9_-], max 32 chars (server cleans anyway).
400
+ detect_project_name() {
401
+ local dir
402
+ dir="$(detect_project_path 2>/dev/null)" || dir="$PWD"
403
+ [[ -z "$dir" ]] && dir="$PWD"
404
+ PN_DIR="$dir" python3 -c '
405
+ import os, re, json
406
+ d = os.environ.get("PN_DIR") or "."
407
+ name = ""
408
+ for mf, kind in [("package.json","json"), ("composer.json","json"),
409
+ ("Cargo.toml","toml"), ("pyproject.toml","toml"),
410
+ ("go.mod","go")]:
411
+ p = os.path.join(d, mf)
412
+ if not os.path.isfile(p): continue
413
+ try:
414
+ txt = open(p, encoding="utf-8").read()
415
+ except Exception:
416
+ continue
417
+ if kind == "json":
418
+ try:
419
+ v = json.loads(txt).get("name")
420
+ if isinstance(v, str) and v:
421
+ name = v; break
422
+ except Exception:
423
+ pass
424
+ elif kind == "toml":
425
+ m = re.search(r"^name\s*=\s*\"([^\"]+)\"", txt, re.M)
426
+ if m: name = m.group(1); break
427
+ elif kind == "go":
428
+ m = re.search(r"^module\s+(\S+)", txt, re.M)
429
+ if m:
430
+ name = m.group(1).rstrip("/").rsplit("/", 1)[-1]; break
431
+ if not name:
432
+ name = os.path.basename(d.rstrip("/")) or "project"
433
+ name = re.sub(r"[^A-Za-z0-9_-]", "", name)[:32]
434
+ sys.stdout.write(name if name else "project")
435
+ ' 2>/dev/null || printf 'project'
436
+ }
437
+
438
+ # ─── Hook input parsing (shared by Stop + PreCompact) ────────────────────────
439
+ # Parse hook stdin JSON → emit "<transcript_path> <session_id>".
440
+ # session_id falls back to transcript_path basename (sans extension).
441
+ # Uses "-" placeholders so empty fields never collapse together.
442
+ hook_paths_from_input() {
443
+ local input="${1:-}"
444
+ HPI_INPUT="$input" python3 -c '
445
+ import os, sys, json
446
+ try:
447
+ d = json.loads(os.environ.get("HPI_INPUT","") or "{}")
448
+ except Exception:
449
+ d = {}
450
+ tp = d.get("transcript_path") or ""
451
+ sid = d.get("session_id") or d.get("sessionId") or ""
452
+ if not sid and tp:
453
+ sid = os.path.splitext(os.path.basename(tp))[0]
454
+ print((tp or "-") + " " + (sid or "-"))
455
+ ' 2>/dev/null || printf '%s\n' "- -"
456
+ }
457
+
458
+ # ─── Session ingest flush (shared by Stop + PreCompact) ──────────────────────
459
+ # Usage: flush_session_ingest <transcript_path> <session_id>
460
+ #
461
+ # Walks transcript JSONL past the saved cursor uuid, filters entries
462
+ # (strip system-reminder/cerebro-*/supermemory-* inject-echo blocks, drop
463
+ # thinking blocks, truncate tool_result 500 / tool_use input 100, collapse
464
+ # whitespace, drop <100-char fragments), POSTs the delta to
465
+ # /v1/memories/session-ingest, and advances the cursor only on HTTP 2xx.
466
+ # Tolerant: never crashes the caller; returns non-zero on network/HTTP failure
467
+ # (cursor NOT advanced → next run retries). Caller should swallow non-zero.
468
+ flush_session_ingest() {
469
+ local transcript_path="${1:-}" sid="${2:-}"
470
+ [[ -z "$transcript_path" || ! -f "$transcript_path" ]] && return 0
471
+ [[ -z "$sid" ]] && return 0
472
+ [[ -z "${OMEM_API_KEY:-}" ]] && return 0
473
+
474
+ local cursor pn pp
475
+ cursor="$(cursor_get "$sid")"
476
+ pn="$(detect_project_name 2>/dev/null || printf 'project')"
477
+ pp="$(detect_project_path 2>/dev/null || true)"
478
+
479
+ # Parse transcript delta → emit "last_uuid\nbody_json".
480
+ # last_uuid = uuid of the last entry processed (advance cursor here)
481
+ # body_json = session-ingest body, or empty when all entries were fragments
482
+ # exits 0 with no output when there are zero new entries past the cursor.
483
+ local parsed last_uuid body
484
+ parsed="$(SI_TP="$transcript_path" SI_CURSOR="$cursor" SI_SID="$sid" \
485
+ SI_PN="$pn" SI_PP="$pp" python3 -c '
486
+ import os, sys, json, re
487
+
488
+ tp = os.environ.get("SI_TP","")
489
+ cursor = os.environ.get("SI_CURSOR","")
490
+ sid = os.environ.get("SI_SID","")
491
+ pn = os.environ.get("SI_PN","")
492
+ pp = os.environ.get("SI_PP","")
493
+
494
+ # Inject-echo blocks we must strip so saved context does not bounce back.
495
+ TAG_RE = re.compile(r"<(system-reminder|cerebro-[a-z0-9_-]+|supermemory-[a-z0-9_-]+)\b[^>]*>[\s\S]*?</\1>", re.I)
496
+ SELFCLOSE_RE = re.compile(r"<(system-reminder|cerebro-[a-z0-9_-]+|supermemory-[a-z0-9_-]+)\b[^>]*/>", re.I)
497
+ WS_RE = re.compile(r"\s+")
498
+
499
+ def clean(s):
500
+ if not isinstance(s, str):
501
+ s = str(s)
502
+ s = TAG_RE.sub("", s)
503
+ s = SELFCLOSE_RE.sub("", s)
504
+ s = WS_RE.sub(" ", s).strip()
505
+ return s
506
+
507
+ def block_text(b):
508
+ t = b.get("type")
509
+ if t == "text":
510
+ return b.get("text","") or ""
511
+ if t == "thinking":
512
+ return None # skip reasoning traces
513
+ if t == "tool_result":
514
+ c = b.get("content","")
515
+ if isinstance(c, list):
516
+ parts = []
517
+ for x in c:
518
+ if isinstance(x, dict) and x.get("type") == "text":
519
+ parts.append(x.get("text","") or "")
520
+ elif isinstance(x, str):
521
+ parts.append(x)
522
+ c = "\n".join(parts)
523
+ elif not isinstance(c, str):
524
+ try:
525
+ c = json.dumps(c, ensure_ascii=False)
526
+ except Exception:
527
+ c = str(c)
528
+ return "tool_result: " + (c or "")[:500]
529
+ if t == "tool_use":
530
+ inp = b.get("input")
531
+ try:
532
+ inp_s = json.dumps(inp, ensure_ascii=False)
533
+ except Exception:
534
+ inp_s = str(inp)
535
+ return "tool_use(%s): %s" % (b.get("name","?"), inp_s[:100])
536
+ return None
537
+
538
+ def content_text(content):
539
+ if isinstance(content, str):
540
+ return content
541
+ if isinstance(content, list):
542
+ parts = []
543
+ for b in content:
544
+ if isinstance(b, dict):
545
+ bt = block_text(b)
546
+ if bt:
547
+ parts.append(bt)
548
+ return "\n".join(parts)
549
+ return ""
550
+
551
+ entries = [] # (uuid, role, raw_text)
552
+ try:
553
+ with open(tp, "r", encoding="utf-8") as f:
554
+ for line in f:
555
+ line = line.strip()
556
+ if not line:
557
+ continue
558
+ try:
559
+ d = json.loads(line)
560
+ except Exception:
561
+ continue
562
+ if d.get("type") not in ("user", "assistant"):
563
+ continue
564
+ uid = d.get("uuid","")
565
+ msg = d.get("message")
566
+ if not isinstance(msg, dict):
567
+ continue
568
+ role = msg.get("role")
569
+ if role not in ("user", "assistant"):
570
+ continue
571
+ raw = content_text(msg.get("content"))
572
+ entries.append((uid, role, raw))
573
+ except Exception:
574
+ entries = []
575
+
576
+ # Locate cursor: entries past it are the delta.
577
+ # If the cursor uuid is not found (e.g. transcript rewritten after compact),
578
+ # fall back to full sweep — safe (server dedups via customId/session_id).
579
+ start = 0
580
+ if cursor:
581
+ for i, (u, _, _) in enumerate(entries):
582
+ if u == cursor:
583
+ start = i + 1
584
+ break
585
+
586
+ new = entries[start:]
587
+ if not new:
588
+ sys.exit(0) # nothing past cursor → no output → no cursor change
589
+
590
+ last_uid = new[-1][0]
591
+ out = []
592
+ for uid, role, raw in new:
593
+ txt = clean(raw)
594
+ if len(txt) < 100: # drop fragments
595
+ continue
596
+ out.append({"role": role, "content": txt})
597
+
598
+ body = {
599
+ "messages": out,
600
+ "agent_id": os.environ.get("OMEM_AGENT_ID", "claude-code"),
601
+ }
602
+ if sid: body["session_id"] = sid
603
+ if pn: body["project_name"] = pn
604
+ if pp: body["project_path"] = pp
605
+
606
+ sys.stdout.write(last_uid + "\n")
607
+ if out:
608
+ sys.stdout.write(json.dumps(body, ensure_ascii=False))
609
+ ' 2>/dev/null)" || parsed=""
610
+
611
+ if [[ -z "$parsed" ]]; then
612
+ return 0 # no new entries past cursor
613
+ fi
614
+ last_uuid="${parsed%%$'\n'*}"
615
+ if [[ "$parsed" == *$'\n'* ]]; then
616
+ body="${parsed#*$'\n'}"
617
+ else
618
+ body=""
619
+ fi
620
+ [[ -z "$last_uuid" ]] && return 0
621
+
622
+ if [[ -n "$body" ]]; then
623
+ local http_code
624
+ http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 25 \
625
+ -X POST \
626
+ -H "X-API-Key: ${OMEM_API_KEY}" \
627
+ -H "Content-Type: application/json" \
628
+ -H "Accept: application/json" \
629
+ -d "$body" \
630
+ "${OMEM_API_URL}/v1/memories/session-ingest" 2>/dev/null) || http_code="000"
631
+ if [[ "$http_code" =~ ^2 ]]; then
632
+ cursor_set "$sid" "$last_uuid"
633
+ log_debug "flush_session_ingest: ok http=$http_code cursor=$last_uuid"
634
+ return 0
635
+ else
636
+ log_error "flush_session_ingest: http=$http_code (cursor NOT advanced, will retry next run)"
637
+ return 1
638
+ fi
639
+ else
640
+ # All new entries were <100-char fragments — nothing to send, but still
641
+ # advance the cursor so we do not re-parse these on every future run.
642
+ cursor_set "$sid" "$last_uuid"
643
+ log_debug "flush_session_ingest: 0 msgs kept (fragments), advancing cursor=$last_uuid"
644
+ return 0
645
+ fi
646
+ }