@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/stop.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cerebro Stop hook — periodic session-ingest flush every N turns
|
|
3
|
+
// Does NOT fire on Ctrl+C interrupt (Claude Code design).
|
|
4
|
+
// SessionEnd detached process covers the interrupt case.
|
|
5
|
+
import { parseStdinJSON, emit, flushSessionIngest, stopCounterGet, stopCounterSet, injectionConfig } from "./common.mjs";
|
|
6
|
+
|
|
7
|
+
const input = parseStdinJSON();
|
|
8
|
+
const tp = input.transcript_path || "";
|
|
9
|
+
const sid = input.session_id || "";
|
|
10
|
+
|
|
11
|
+
const stopCfg = injectionConfig.stopFlush || {};
|
|
12
|
+
if (stopCfg.enabled === false || !tp || !sid) {
|
|
13
|
+
emit({});
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const interval = stopCfg.interval || 5;
|
|
18
|
+
const counter = stopCounterGet(sid);
|
|
19
|
+
const newCount = counter + 1;
|
|
20
|
+
|
|
21
|
+
if (newCount < interval) {
|
|
22
|
+
stopCounterSet(sid, newCount);
|
|
23
|
+
emit({});
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Threshold reached — flush
|
|
28
|
+
stopCounterSet(sid, 0);
|
|
29
|
+
const result = await flushSessionIngest(tp, sid).catch(() => ({ ok: false, count: 0 }));
|
|
30
|
+
|
|
31
|
+
emit({
|
|
32
|
+
systemMessage: result.ok
|
|
33
|
+
? `🧠 Cerebro · Auto-saved · ${result.count} messages ingested`
|
|
34
|
+
: `🧠 Cerebro · Auto-save failed (will retry next flush)`,
|
|
35
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cerebro UserPromptSubmit hook — reasoned recall instruction + keyword nudge
|
|
3
|
+
// Injection content is config-driven (~/.claude/cerebro.json), not hardcoded.
|
|
4
|
+
import { parseStdinJSON, emit, postRecallEvent, injectionConfig } from "./common.mjs";
|
|
5
|
+
|
|
6
|
+
const input = parseStdinJSON();
|
|
7
|
+
const prompt = typeof input === "object" ? input.prompt || input.message || "" : "";
|
|
8
|
+
const sid = input.session_id || "";
|
|
9
|
+
|
|
10
|
+
// ─── Build injection from config ────────────────────────────────────────────
|
|
11
|
+
const parts = [];
|
|
12
|
+
|
|
13
|
+
// Recall instruction
|
|
14
|
+
const recallCfg = injectionConfig.recall || {};
|
|
15
|
+
if (recallCfg.enabled !== false && recallCfg.prompt) {
|
|
16
|
+
parts.push(`<cerebro-recall>\n${recallCfg.prompt}\n</cerebro-recall>`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Keyword nudge
|
|
20
|
+
const nudgeCfg = injectionConfig.nudge || {};
|
|
21
|
+
if (nudgeCfg.enabled !== false) {
|
|
22
|
+
const pLow = prompt.toLowerCase();
|
|
23
|
+
const nudges = [];
|
|
24
|
+
const saveKw = nudgeCfg.saveKeywords || [];
|
|
25
|
+
const recallKw = nudgeCfg.recallKeywords || [];
|
|
26
|
+
|
|
27
|
+
if (saveKw.some((kw) => pLow.includes(String(kw).toLowerCase())) && nudgeCfg.savePrompt) {
|
|
28
|
+
nudges.push(`<cerebro-nudge>${nudgeCfg.savePrompt}</cerebro-nudge>`);
|
|
29
|
+
}
|
|
30
|
+
if (recallKw.some((kw) => pLow.includes(String(kw).toLowerCase())) && nudgeCfg.recallPrompt) {
|
|
31
|
+
nudges.push(`<cerebro-nudge>${nudgeCfg.recallPrompt}</cerebro-nudge>`);
|
|
32
|
+
}
|
|
33
|
+
if (nudges.length) parts.push(nudges.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const injectionText = parts.join("\n\n");
|
|
37
|
+
|
|
38
|
+
// ─── POST recall-event (web Sessions page) ───────────────────────────────────
|
|
39
|
+
await postRecallEvent({
|
|
40
|
+
sessionId: sid,
|
|
41
|
+
recallType: "auto",
|
|
42
|
+
queryText: prompt,
|
|
43
|
+
profileInjected: false,
|
|
44
|
+
keptCount: 0,
|
|
45
|
+
injectedContent: injectionText,
|
|
46
|
+
maxScore: 0,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
emit({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: injectionText } });
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mingxy/cerebro-claude-code",
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Persistent memory for Claude Code — memories survive across sessions, projects, and machines",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "mingxy-cerebro",
|
|
7
|
+
"email": "hi@cerebro.dev"
|
|
8
|
+
},
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"homepage": "https://github.com/mingxy-cerebro/cerebro-server",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/mingxy-cerebro/cerebro-server",
|
|
14
|
+
"directory": "plugins/claude-code"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude-code",
|
|
18
|
+
"memory",
|
|
19
|
+
"persistent",
|
|
20
|
+
"agent-memory",
|
|
21
|
+
"context",
|
|
22
|
+
"recall",
|
|
23
|
+
"profile"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cerebro Claude Code plugin — memory-profile skill
|
|
3
|
+
#
|
|
4
|
+
# Retrieve the synthesized user profile (preferences, patterns, identity traits)
|
|
5
|
+
# derived from stored memories. Mirrors plugins/opencode/src/tools.ts::memory_profile.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# bash memory-profile.sh
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
|
12
|
+
if [[ -z "$PLUGIN_ROOT" ]]; then
|
|
13
|
+
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)" || \
|
|
14
|
+
PLUGIN_ROOT="$(dirname "${BASH_SOURCE[0]:-$0}")/.."
|
|
15
|
+
fi
|
|
16
|
+
# shellcheck source=../hooks/common.sh
|
|
17
|
+
source "${PLUGIN_ROOT}/hooks/common.sh"
|
|
18
|
+
|
|
19
|
+
pp="$(detect_project_path || true)"
|
|
20
|
+
|
|
21
|
+
# ─── Build URL-encoded path ──────────────────────────────────────────────────
|
|
22
|
+
url_path="$(MEM_PP="$pp" python3 -c '
|
|
23
|
+
import os, urllib.parse, sys
|
|
24
|
+
pp = (os.environ.get("MEM_PP") or "").strip()
|
|
25
|
+
path = "/v2/profile"
|
|
26
|
+
if pp:
|
|
27
|
+
path += "?project_path=" + urllib.parse.quote(pp, safe="")
|
|
28
|
+
sys.stdout.write(path)
|
|
29
|
+
')"
|
|
30
|
+
|
|
31
|
+
# ─── Fetch + format ──────────────────────────────────────────────────────────
|
|
32
|
+
resp="$(omem_get "$url_path")"
|
|
33
|
+
|
|
34
|
+
printf '%s' "$resp" | python3 -c '
|
|
35
|
+
import sys, json
|
|
36
|
+
raw = sys.stdin.read()
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(raw)
|
|
39
|
+
except Exception:
|
|
40
|
+
print("error: invalid JSON response from server")
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
if isinstance(data, dict) and data.get("error"):
|
|
43
|
+
print("error: " + str(data["error"]))
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
# Server may return either a bare PreferenceDto[] list or {preferences:[...]}.
|
|
46
|
+
if isinstance(data, list):
|
|
47
|
+
prefs = data
|
|
48
|
+
elif isinstance(data, dict):
|
|
49
|
+
prefs = data.get("preferences") or data.get("results") or []
|
|
50
|
+
else:
|
|
51
|
+
prefs = []
|
|
52
|
+
if not prefs:
|
|
53
|
+
print("no profile preferences")
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
for p in prefs:
|
|
56
|
+
if not isinstance(p, dict):
|
|
57
|
+
continue
|
|
58
|
+
slot = p.get("slot", "?")
|
|
59
|
+
val = p.get("value", "")
|
|
60
|
+
conf = p.get("confidence", 0)
|
|
61
|
+
scp = p.get("scope", "")
|
|
62
|
+
try:
|
|
63
|
+
line = "%s: %s (conf=%.2f, scope=%s)" % (slot, val, float(conf), scp)
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
line = "%s: %s (conf=%s, scope=%s)" % (slot, val, conf, scp)
|
|
66
|
+
print(line)
|
|
67
|
+
'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cerebro Claude Code plugin — memory-save skill
|
|
3
|
+
#
|
|
4
|
+
# Persist a fact / decision / preference to long-term memory. Mirrors the
|
|
5
|
+
# LLM-facing contract of plugins/opencode/src/tools.ts::memory_store.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# bash memory-save.sh --content "..." [--tags "t1,t2"] [--category X] \
|
|
9
|
+
# [--visibility global|private] [--scope project|global]
|
|
10
|
+
# echo "..." | bash memory-save.sh # content from stdin
|
|
11
|
+
#
|
|
12
|
+
# Category enum (lowercase): cases | preferences | entities | events | profile | patterns
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
|
16
|
+
if [[ -z "$PLUGIN_ROOT" ]]; then
|
|
17
|
+
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)" || \
|
|
18
|
+
PLUGIN_ROOT="$(dirname "${BASH_SOURCE[0]:-$0}")/.."
|
|
19
|
+
fi
|
|
20
|
+
# shellcheck source=../hooks/common.sh
|
|
21
|
+
source "${PLUGIN_ROOT}/hooks/common.sh"
|
|
22
|
+
|
|
23
|
+
# ─── Arg parsing ─────────────────────────────────────────────────────────────
|
|
24
|
+
content=""
|
|
25
|
+
tags_arg=""
|
|
26
|
+
category=""
|
|
27
|
+
visibility="global"
|
|
28
|
+
scope="project"
|
|
29
|
+
|
|
30
|
+
while [[ $# -gt 0 ]]; do
|
|
31
|
+
case "$1" in
|
|
32
|
+
--content) content="${2:-}"; shift 2 ;;
|
|
33
|
+
--tags) tags_arg="${2:-}"; shift 2 ;;
|
|
34
|
+
--category) category="${2:-}"; shift 2 ;;
|
|
35
|
+
--visibility) visibility="${2:-}"; shift 2 ;;
|
|
36
|
+
--scope) scope="${2:-}"; shift 2 ;;
|
|
37
|
+
--help|-h)
|
|
38
|
+
sed -n '2,15p' "${BASH_SOURCE[0]:-$0}" >&2; exit 0 ;;
|
|
39
|
+
--*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
40
|
+
*) content="$1"; shift ;;
|
|
41
|
+
esac
|
|
42
|
+
done
|
|
43
|
+
|
|
44
|
+
# stdin fallback when no --content / positional given
|
|
45
|
+
if [[ -z "$content" && ! -t 0 ]]; then
|
|
46
|
+
content="$(cat || true)"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
if [[ -z "$content" ]]; then
|
|
50
|
+
echo 'usage: memory-save.sh --content "..." [--tags t1,t2] [--category X] [--visibility global|private] [--scope project|global]' >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Basic enum validation (fail fast with a friendly message, do not crash harder).
|
|
55
|
+
case "$visibility" in
|
|
56
|
+
global|private) : ;;
|
|
57
|
+
*) echo "error: --visibility must be 'global' or 'private' (got: $visibility)" >&2; exit 2 ;;
|
|
58
|
+
esac
|
|
59
|
+
case "$scope" in
|
|
60
|
+
project|global) : ;;
|
|
61
|
+
*) echo "error: --scope must be 'project' or 'global' (got: $scope)" >&2; exit 2 ;;
|
|
62
|
+
esac
|
|
63
|
+
if [[ -n "$category" ]]; then
|
|
64
|
+
case "$category" in
|
|
65
|
+
cases|preferences|entities|events|profile|patterns) : ;;
|
|
66
|
+
*) echo "error: --category must be one of cases|preferences|entities|events|profile|patterns (got: $category)" >&2; exit 2 ;;
|
|
67
|
+
esac
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# ─── Sanitize + assemble tags + project_path ─────────────────────────────────
|
|
71
|
+
content="$(printf '%s' "$content" | sanitize_content)"
|
|
72
|
+
container="$(container_tags || true)"
|
|
73
|
+
pp="$(detect_project_path || true)"
|
|
74
|
+
|
|
75
|
+
# ─── Build JSON body via python3 ─────────────────────────────────────────────
|
|
76
|
+
body="$(MEM_CONTENT="$content" MEM_CONTAINER="$container" MEM_TAGS="$tags_arg" \
|
|
77
|
+
MEM_CAT="$category" MEM_VIS="$visibility" MEM_SCOPE="$scope" MEM_PP="$pp" \
|
|
78
|
+
python3 -c '
|
|
79
|
+
import os, json, sys
|
|
80
|
+
content = os.environ["MEM_CONTENT"]
|
|
81
|
+
container = (os.environ.get("MEM_CONTAINER") or "").split()
|
|
82
|
+
user_tags = [t.strip() for t in (os.environ.get("MEM_TAGS") or "").split(",") if t.strip()]
|
|
83
|
+
tags = container + user_tags
|
|
84
|
+
body = {
|
|
85
|
+
"content": content,
|
|
86
|
+
"tags": tags,
|
|
87
|
+
"source": "claude-code",
|
|
88
|
+
"scope": os.environ.get("MEM_SCOPE") or "project",
|
|
89
|
+
"agent_id": "cerebro",
|
|
90
|
+
"visibility": os.environ.get("MEM_VIS") or "global",
|
|
91
|
+
}
|
|
92
|
+
pp = (os.environ.get("MEM_PP") or "").strip()
|
|
93
|
+
if pp:
|
|
94
|
+
body["project_path"] = pp
|
|
95
|
+
cat = (os.environ.get("MEM_CAT") or "").strip()
|
|
96
|
+
if cat:
|
|
97
|
+
body["category"] = cat
|
|
98
|
+
sys.stdout.write(json.dumps(body, ensure_ascii=False))
|
|
99
|
+
')"
|
|
100
|
+
|
|
101
|
+
# ─── POST + format response ──────────────────────────────────────────────────
|
|
102
|
+
resp="$(omem_post "/v1/memories" "$body")"
|
|
103
|
+
|
|
104
|
+
printf '%s' "$resp" | python3 -c '
|
|
105
|
+
import sys, json
|
|
106
|
+
raw = sys.stdin.read()
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(raw)
|
|
109
|
+
except Exception:
|
|
110
|
+
print("error: invalid JSON response from server")
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
if isinstance(data, dict) and data.get("error"):
|
|
113
|
+
print("error: " + str(data["error"]))
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
print("ok")
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
mid = data.get("id")
|
|
119
|
+
extra = ""
|
|
120
|
+
tags = data.get("tags") or []
|
|
121
|
+
if isinstance(tags, list) and tags:
|
|
122
|
+
extra = " tags=" + ",".join(str(t) for t in tags)
|
|
123
|
+
if mid is not None:
|
|
124
|
+
print("ok id=" + str(mid) + extra)
|
|
125
|
+
else:
|
|
126
|
+
print("ok" + extra)
|
|
127
|
+
'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cerebro Claude Code plugin — memory-search skill
|
|
3
|
+
#
|
|
4
|
+
# Semantic search over long-term memory. Mirrors the LLM-facing contract of
|
|
5
|
+
# plugins/opencode/src/tools.ts::memory_search but shells out via common.sh.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# bash memory-search.sh "QUERY" [LIMIT]
|
|
9
|
+
# echo "auth flow" | bash memory-search.sh - [LIMIT]
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
# Locate plugin root (CLAUDE_PLUGIN_ROOT when invoked by Claude Code; otherwise
|
|
13
|
+
# derive from this script's location: scripts/foo.sh -> .. = plugin root).
|
|
14
|
+
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
|
15
|
+
if [[ -z "$PLUGIN_ROOT" ]]; then
|
|
16
|
+
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." 2>/dev/null && pwd)" || \
|
|
17
|
+
PLUGIN_ROOT="$(dirname "${BASH_SOURCE[0]:-$0}")/.."
|
|
18
|
+
fi
|
|
19
|
+
# shellcheck source=../hooks/common.sh
|
|
20
|
+
source "${PLUGIN_ROOT}/hooks/common.sh"
|
|
21
|
+
|
|
22
|
+
# ─── Args ────────────────────────────────────────────────────────────────────
|
|
23
|
+
query="${1:-}"
|
|
24
|
+
limit="${2:-${MEM_SEARCH_COUNT:-8}}"
|
|
25
|
+
|
|
26
|
+
# Allow `echo "q" | memory-search.sh - [LIMIT]` for stdin-driven queries.
|
|
27
|
+
if [[ "$query" == "-" || (-z "$query" && ! -t 0) ]]; then
|
|
28
|
+
query=$(cat || true)
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
if [[ -z "$query" ]]; then
|
|
32
|
+
echo "usage: memory-search.sh \"QUERY\" [LIMIT]" >&2
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
query="$(truncate_query "$query")"
|
|
37
|
+
tags="$(container_tags || true)"
|
|
38
|
+
pp="$(detect_project_path || true)"
|
|
39
|
+
|
|
40
|
+
# ─── Build URL-encoded GET path via python3 (avoid bash quoting traps) ───────
|
|
41
|
+
url_path="$(MEM_Q="$query" MEM_TAGS="$tags" MEM_PP="$pp" MEM_LIMIT="$limit" python3 -c '
|
|
42
|
+
import os, urllib.parse, sys
|
|
43
|
+
q = os.environ.get("MEM_Q", "")
|
|
44
|
+
limit = os.environ.get("MEM_LIMIT", "8")
|
|
45
|
+
parts = [
|
|
46
|
+
"q=" + urllib.parse.quote(q, safe=""),
|
|
47
|
+
"limit=" + urllib.parse.quote(str(limit), safe=""),
|
|
48
|
+
]
|
|
49
|
+
tags_raw = (os.environ.get("MEM_TAGS") or "").strip()
|
|
50
|
+
if tags_raw:
|
|
51
|
+
comma = ",".join(t for t in tags_raw.split() if t)
|
|
52
|
+
if comma:
|
|
53
|
+
# comma is reserved-safe in query per RFC 3986; keep literal
|
|
54
|
+
parts.append("tags=" + ",".join(urllib.parse.quote(t, safe="") for t in comma.split(",")))
|
|
55
|
+
pp = (os.environ.get("MEM_PP") or "").strip()
|
|
56
|
+
if pp:
|
|
57
|
+
parts.append("project_path=" + urllib.parse.quote(pp, safe=""))
|
|
58
|
+
sys.stdout.write("/v1/memories/search?" + "&".join(parts))
|
|
59
|
+
')"
|
|
60
|
+
|
|
61
|
+
# ─── Fetch + format ──────────────────────────────────────────────────────────
|
|
62
|
+
resp="$(omem_get "$url_path")"
|
|
63
|
+
|
|
64
|
+
printf '%s' "$resp" | python3 -c '
|
|
65
|
+
import sys, json
|
|
66
|
+
raw = sys.stdin.read()
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(raw)
|
|
69
|
+
except Exception:
|
|
70
|
+
print("error: invalid JSON response from server")
|
|
71
|
+
sys.exit(0)
|
|
72
|
+
if isinstance(data, dict) and data.get("error"):
|
|
73
|
+
print("error: " + str(data["error"]))
|
|
74
|
+
sys.exit(0)
|
|
75
|
+
results = data.get("results", []) if isinstance(data, dict) else []
|
|
76
|
+
if not results:
|
|
77
|
+
print("no memories")
|
|
78
|
+
sys.exit(0)
|
|
79
|
+
for r in results:
|
|
80
|
+
if not isinstance(r, dict):
|
|
81
|
+
continue
|
|
82
|
+
m = r.get("memory") or {}
|
|
83
|
+
if not isinstance(m, dict):
|
|
84
|
+
m = {}
|
|
85
|
+
score = r.get("score", 0.0)
|
|
86
|
+
mid = m.get("id", "?")
|
|
87
|
+
content = (m.get("content") or "")
|
|
88
|
+
snippet = content[:200]
|
|
89
|
+
try:
|
|
90
|
+
line = "[%.2f] %s: %s" % (float(score), mid, snippet)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
line = "[%s] %s: %s" % (score, mid, snippet)
|
|
93
|
+
print(line)
|
|
94
|
+
'
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cerebro web-server — CC 插件独立进程(移植自 plugins/opencode/src/web-server.ts)
|
|
3
|
+
// 由 session-start.sh 经 setsid detach 拉起,多 CC session 共享端口 5212。
|
|
4
|
+
// 与 opencode 版的区别:去掉 takeover 定时器(SessionStart probe 已够),纯静态 serve。
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
const PID_FILE = path.join(process.env.HOME || process.env.USERPROFILE || "", ".config/cerebro/web-server.pid");
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PORT = parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || 5212;
|
|
15
|
+
const API_URL = process.env.OMEM_API_URL || "https://www.mengxy.cc";
|
|
16
|
+
|
|
17
|
+
// web 目录查找:env > 本地 web/ > 兄弟 opencode/web/
|
|
18
|
+
function findWebDir() {
|
|
19
|
+
const candidates = [
|
|
20
|
+
process.env.CEREBRO_WEB_DIR,
|
|
21
|
+
path.resolve(__dirname, "../web"),
|
|
22
|
+
path.resolve(__dirname, "../../opencode/web"),
|
|
23
|
+
].filter(Boolean);
|
|
24
|
+
for (const d of candidates) {
|
|
25
|
+
if (fs.existsSync(path.join(d, "index.html"))) return d;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WEB_DIR = findWebDir();
|
|
31
|
+
if (!WEB_DIR) {
|
|
32
|
+
console.error("[cerebro web-server] no web directory found, exiting");
|
|
33
|
+
process.exit(0); // exit 0 — not an error, just nothing to serve
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COMMON = { "X-Content-Type-Options": "nosniff" };
|
|
37
|
+
const MIME = {
|
|
38
|
+
".html": "text/html; charset=utf-8",
|
|
39
|
+
".js": "application/javascript; charset=utf-8",
|
|
40
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
41
|
+
".css": "text/css; charset=utf-8",
|
|
42
|
+
".json": "application/json; charset=utf-8",
|
|
43
|
+
".svg": "image/svg+xml",
|
|
44
|
+
".png": "image/png",
|
|
45
|
+
".jpg": "image/jpeg",
|
|
46
|
+
".ico": "image/x-icon",
|
|
47
|
+
".woff": "font/woff",
|
|
48
|
+
".woff2": "font/woff2",
|
|
49
|
+
".ttf": "font/ttf",
|
|
50
|
+
".webp": "image/webp",
|
|
51
|
+
".map": "application/json",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function resolveSafe(baseDir, pathname) {
|
|
55
|
+
const rel = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
56
|
+
const resolved = path.resolve(baseDir, rel || ".");
|
|
57
|
+
if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) return null;
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function serveFile(res, filePath) {
|
|
62
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
63
|
+
fs.readFile(filePath, (err, data) => {
|
|
64
|
+
if (err) {
|
|
65
|
+
res.writeHead(500, { ...COMMON, "Content-Type": "text/plain" });
|
|
66
|
+
res.end("Internal Server Error");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let body = data;
|
|
70
|
+
if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
|
|
71
|
+
body = data.toString("utf-8").replace(
|
|
72
|
+
/window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
|
|
73
|
+
`window.__OMEM_API_URL__ = ${JSON.stringify(API_URL)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
res.writeHead(200, {
|
|
77
|
+
...COMMON,
|
|
78
|
+
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
79
|
+
"Cache-Control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400",
|
|
80
|
+
});
|
|
81
|
+
res.end(body);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const indexPath = path.join(WEB_DIR, "index.html");
|
|
86
|
+
const server = http.createServer((req, res) => {
|
|
87
|
+
if (req.url === "/health" || req.url === "/health/") {
|
|
88
|
+
res.writeHead(200, { ...COMMON, "Content-Type": "application/json" });
|
|
89
|
+
res.end(JSON.stringify({ status: "ok", service: "cerebro", port: PORT }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
93
|
+
res.writeHead(405, { ...COMMON, "Content-Type": "text/plain" });
|
|
94
|
+
res.end("Method Not Allowed");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const url = new URL(req.url || "/", `http://localhost:${PORT}`);
|
|
98
|
+
const safePath = resolveSafe(WEB_DIR, url.pathname);
|
|
99
|
+
if (!safePath) {
|
|
100
|
+
res.writeHead(403, { ...COMMON, "Content-Type": "text/plain" });
|
|
101
|
+
res.end("Forbidden");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
fs.stat(safePath, (statErr, stats) => {
|
|
105
|
+
if (!statErr && stats.isFile()) { serveFile(res, safePath); return; }
|
|
106
|
+
fs.stat(indexPath, (idxErr, idxStats) => {
|
|
107
|
+
if (idxErr || !idxStats.isFile()) {
|
|
108
|
+
res.writeHead(404, { ...COMMON, "Content-Type": "text/plain" });
|
|
109
|
+
res.end("Not Found");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
serveFile(res, indexPath);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
server.on("error", (err) => {
|
|
118
|
+
if (err.code === "EADDRINUSE") {
|
|
119
|
+
// 另一个 CC session / opencode 已占端口 — 正常,静默退出
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
console.error(`[cerebro web-server] error: ${err.message}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
127
|
+
try { writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
128
|
+
console.log(`[cerebro web-server] serving ${WEB_DIR} at http://localhost:${PORT} (pid=${process.pid})`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
process.on("SIGTERM", () => server.close(() => { try { unlinkSync(PID_FILE); } catch {} process.exit(0); }));
|
|
132
|
+
process.on("SIGINT", () => server.close(() => { try { unlinkSync(PID_FILE); } catch {} process.exit(0); }));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: memory-profile
|
|
3
|
+
description: Retrieve the synthesized user profile (preferences, patterns, identity traits) induced from stored memories. Use at session start to ground yourself in the user's working style, tooling preferences, recurring workflows, and role context; or whenever adapting tone, format, or approach to match the user's established patterns would improve the interaction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Memory Profile
|
|
7
|
+
|
|
8
|
+
Read the induced preference profile for the current user/project. Each preference is a `{slot, value, confidence, scope}` tuple synthesized from raw memories.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- Session bootstrap — load working-style context before doing work
|
|
13
|
+
- User asks "what do you know about me / my preferences / how I work"
|
|
14
|
+
- Adapting response format, tone, or tooling to the user's established patterns
|
|
15
|
+
- Checking whether a candidate action aligns with stored preferences
|
|
16
|
+
|
|
17
|
+
## How to run
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bash "$CLAUDE_PLUGIN_ROOT/scripts/memory-profile.sh"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No arguments. `project_path` is auto-detected from git toplevel and forwarded to the server.
|
|
24
|
+
|
|
25
|
+
## Output format
|
|
26
|
+
|
|
27
|
+
One line per preference:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
preferred_language: rust (conf=0.92, scope=global)
|
|
31
|
+
review_style: terse, no praise (conf=0.85, scope=project)
|
|
32
|
+
indent_style: 2-space (conf=0.78, scope=global)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`no profile preferences` when the profile is empty. `error: …` on transport/server failure.
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
- Confidence (`conf`) is the induction strength — treat values below ~0.5 as weak signals.
|
|
40
|
+
- `scope=global` preferences apply across projects; `scope=project` only to the current one.
|
|
41
|
+
- Profile is read-only here. To influence it, save memories via `memory-save` with `category=preferences` or `category=profile`; the server's induction pipeline will fold them in.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: memory-save
|
|
3
|
+
description: Persist a fact, decision, or preference to the user's long-term memory (Cerebro). Use when the user explicitly says "remember / save / store / don't forget / note this", or when you identify important information worth preserving across sessions — preferences, coding style, architecture decisions, bug fixes, project entities, milestones, workflows, or user identity traits. Each memory must be atomic (one fact), self-contained, and precise. Private secrets/credentials/personal data MUST use visibility=private; cross-project knowledge uses scope=global.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Memory Save
|
|
7
|
+
|
|
8
|
+
Store one atomic, self-contained memory. Before calling, decide:
|
|
9
|
+
1. **category** — which of the 6 buckets does this belong to?
|
|
10
|
+
2. **scope** — project-specific or cross-project?
|
|
11
|
+
3. **visibility** — does it contain sensitive data?
|
|
12
|
+
4. **tags** — at least one descriptive snake_case tag.
|
|
13
|
+
|
|
14
|
+
## Category enum (lowercase, exact)
|
|
15
|
+
|
|
16
|
+
| Value | Use for |
|
|
17
|
+
|-------|---------|
|
|
18
|
+
| `cases` (default) | Work records, bug fixes, architecture decisions, troubleshooting notes |
|
|
19
|
+
| `preferences` | User likes/dislikes, coding style, tool choices, review habits |
|
|
20
|
+
| `entities` | Projects, tools, libraries, people, concepts worth remembering |
|
|
21
|
+
| `events` | Time-bound milestones — deployments, releases, incidents, deadlines |
|
|
22
|
+
| `profile` | User identity traits — role, skills, team membership, timezone |
|
|
23
|
+
| `patterns` | Workflows, methodologies, recurring best practices |
|
|
24
|
+
|
|
25
|
+
## Visibility
|
|
26
|
+
|
|
27
|
+
- `global` (default) — all agents can see and recall. Correct for normal work notes.
|
|
28
|
+
- `private` — ONLY the current agent sees it. **MUST use for**: passwords, API keys, tokens, DB credentials, SSH keys, personal info (phone/email/address), internal company details, or anything the user would not want other agents to access. When in doubt, ask the user.
|
|
29
|
+
|
|
30
|
+
## Scope
|
|
31
|
+
|
|
32
|
+
- `project` (default) — visible only in this project's context (auto `project_path`).
|
|
33
|
+
- `global` — visible across all projects. Use for user preferences, general knowledge, cross-project patterns.
|
|
34
|
+
|
|
35
|
+
## How to run
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bash "$CLAUDE_PLUGIN_ROOT/scripts/memory-save.sh" \
|
|
39
|
+
--content "Fixed memory_type validation bug in memory.rs:1480 — LLM returned illegal 'pinned' value, added match guard normalizing to WORK/EMOTIONAL fallback" \
|
|
40
|
+
--tags "rust_backend,memory_system,bug_fix" \
|
|
41
|
+
--category cases \
|
|
42
|
+
--scope project \
|
|
43
|
+
--visibility global
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Content can also be piped:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
echo "user prefers terse caveman-style replies" | \
|
|
50
|
+
bash "$CLAUDE_PLUGIN_ROOT/scripts/memory-save.sh" --tags "communication,style" --category preferences --scope global
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Writing good content
|
|
54
|
+
|
|
55
|
+
- BAD: "fixed some bugs"
|
|
56
|
+
- GOOD: "Fixed refresh-token rotation bug in auth.rs:230 — replay window allowed token reuse; added nonce cache with 60s TTL."
|
|
57
|
+
- BAD: "user likes stuff"
|
|
58
|
+
- GOOD: "User prefers dark IDE theme, 2-space indent for Rust, tabs for Go."
|
|
59
|
+
|
|
60
|
+
## Output
|
|
61
|
+
|
|
62
|
+
- Success: `ok id=mem_abc123 tags=omem_user_xxx,rust_backend`
|
|
63
|
+
- Failure: `error: <reason>`
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
|
|
67
|
+
- Content is sanitized (XML tags stripped, whitespace collapsed, truncated to `$MEM_MAX_CONTENT`).
|
|
68
|
+
- Container tags (`omem_user_<hash>`, `omem_project_<hash>`) are auto-prepended.
|
|
69
|
+
- `project_path` is auto-detected from git toplevel.
|
|
70
|
+
- `source` is hardcoded to `claude-code`, `agent_id` to `cerebro`.
|