@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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.mcp.json +10 -0
- package/README.md +159 -0
- package/config.json +22 -0
- package/hooks/common.mjs +687 -0
- package/hooks/common.sh +646 -0
- package/hooks/dream.mjs +240 -0
- package/hooks/flush-detached.mjs +45 -0
- package/hooks/hooks.json +83 -0
- package/hooks/post-compact.mjs +49 -0
- package/hooks/pre-compact.mjs +26 -0
- package/hooks/recall-approve.mjs +26 -0
- package/hooks/session-end.mjs +34 -0
- package/hooks/session-start.mjs +156 -0
- package/hooks/stop.mjs +35 -0
- package/hooks/user-prompt-submit.mjs +49 -0
- package/package.json +28 -0
- package/scripts/memory-profile.sh +67 -0
- package/scripts/memory-save.sh +127 -0
- package/scripts/memory-search.sh +94 -0
- package/scripts/web-server.mjs +132 -0
- package/skills/memory-profile/SKILL.md +41 -0
- package/skills/memory-save/SKILL.md +70 -0
- package/skills/memory-search/SKILL.md +47 -0
- package/tests/common.test.mjs +123 -0
- package/tests/hooks.test.mjs +111 -0
- package/tests/test_smoke.sh +233 -0
- package/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/web/assets/index-DVguCuEA.css +1 -0
- package/web/assets/index-Z_oxQQlO.js +165 -0
- package/web/favicon.svg +1 -0
- package/web/icons.svg +24 -0
- package/web/index.html +15 -0
package/hooks/common.sh
ADDED
|
@@ -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
|
+
}
|