@mingxy/cerebro-claude-code 0.3.7 → 0.3.9

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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +0 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +2 -1
  4. package/README.md +0 -0
  5. package/config.json +0 -0
  6. package/hooks/apply-dream.mjs +11 -1
  7. package/hooks/common.mjs +36 -6
  8. package/hooks/common.sh +0 -0
  9. package/hooks/dream.mjs +0 -0
  10. package/hooks/flush-detached.mjs +0 -0
  11. package/hooks/hooks.json +0 -0
  12. package/hooks/post-compact.mjs +0 -0
  13. package/hooks/pre-compact.mjs +0 -0
  14. package/hooks/recall-approve.mjs +0 -0
  15. package/hooks/session-end.mjs +0 -0
  16. package/hooks/session-start.mjs +2 -2
  17. package/hooks/stop.mjs +0 -0
  18. package/hooks/user-prompt-submit.mjs +0 -0
  19. package/package.json +1 -1
  20. package/scripts/memory-profile.sh +0 -0
  21. package/scripts/memory-save.sh +0 -0
  22. package/scripts/memory-search.sh +0 -0
  23. package/scripts/web-server.mjs +0 -0
  24. package/skills/apply-dream/SKILL.md +0 -0
  25. package/skills/dream/SKILL.md +0 -0
  26. package/skills/memory-profile/SKILL.md +0 -0
  27. package/skills/memory-save/SKILL.md +0 -0
  28. package/skills/memory-search/SKILL.md +0 -0
  29. package/tests/common.test.mjs +0 -0
  30. package/tests/hooks.test.mjs +37 -0
  31. package/tests/test_smoke.sh +0 -0
  32. package/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  33. package/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  34. package/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  35. package/web/assets/index-DVguCuEA.css +0 -0
  36. package/web/assets/index-zOJZZn-i.js +0 -0
  37. package/web/favicon.svg +0 -0
  38. package/web/icons.svg +0 -0
  39. package/web/index.html +0 -0
File without changes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cerebro",
3
3
  "description": "Persistent memory for Claude Code — memories survive across sessions, projects, and machines",
4
- "version": "0.3.7",
4
+ "version": "0.3.9",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
7
7
  "email": "hi@cerebro.dev"
package/.mcp.json CHANGED
@@ -4,7 +4,8 @@
4
4
  "args": ["-y", "@mingxy/cerebro-mcp@latest"],
5
5
  "env": {
6
6
  "OMEM_API_KEY": "${OMEM_API_KEY}",
7
- "OMEM_API_URL": "${OMEM_API_URL:-https://www.mengxy.cc}"
7
+ "OMEM_API_URL": "${OMEM_API_URL:-https://www.mengxy.cc}",
8
+ "OMEM_DISABLE_STORE": "1"
8
9
  }
9
10
  }
10
11
  }
package/README.md CHANGED
File without changes
package/config.json CHANGED
File without changes
@@ -5,6 +5,8 @@
5
5
  // unknown kept name → surfaced as `unknown`, NEVER silently dropped (that is memory evaporation)
6
6
  // dropped → removal candidate, listed for review; applied only with --apply
7
7
  // merged/updated/added → LLM version wins (content is allowed to change there)
8
+ // except: added onto an existing local file is a conflict (LLM relabeled an
9
+ // existing memory as new) — skipped and surfaced, never blindly overwrites
8
10
  // stats.total still counts kept entries — the ledger is the server's, not ours to re-derive
9
11
  //
10
12
  // Read-only review by default (prints the diff); `--apply` writes after user approval.
@@ -88,6 +90,7 @@ let entries = JSON.parse(readFileSync(archivePath, "utf8")).entries || [];
88
90
  // ─── merge ────────────────────────────────────────────────────────────────────
89
91
  const unknown = []; // kept names missing from the old archive — LLM renamed, memory at risk
90
92
  const empty = []; // content actions with empty body+description — never written, surfaced
93
+ const conflict = []; // added but a local file with that name exists — LLM rewrite mislabeled as new
91
94
  const report = { keep: 0, write: [], drop: [], dropCount: 0 };
92
95
  for (let e of entries) {
93
96
  // normalize BEFORE any branch: an LLM-emitted Chinese name that the MEMORY.md
@@ -105,6 +108,11 @@ for (let e of entries) {
105
108
  // 60-byte frontmatter shell. merged/updated skip = old content survives;
106
109
  // added skip = surfaced below, not silently dropped.
107
110
  if (!(e.body || "").trim() && !(e.description || "").trim()) { empty.push(`${e.name} (${act})`); continue; }
111
+ // added onto an existing local file = the LLM relabeled a rewrite as new
112
+ // (deepseek tic, cf. the 2026-08-20 archive where 6 existing entries came
113
+ // back as added with condensed bodies and zero new info). Its content
114
+ // would trade a hand-written file for a stub — skip, surface, human decides.
115
+ if (act === "added" && oldFiles.has(e.name)) { conflict.push(e.name); continue; }
108
116
  report.write.push(e); // LLM content is authoritative for these actions
109
117
  } else {
110
118
  unknown.push(`${e.name} (action=${act || "?"})`);
@@ -114,15 +122,17 @@ for (let e of entries) {
114
122
  // ─── review output ────────────────────────────────────────────────────────────
115
123
  const lines = [
116
124
  `apply-dream review · archive ${archivePath}`,
117
- `kept ${report.keep} / write ${report.write.length} / drop ${report.dropCount} / unknown ${unknown.length}`,
125
+ `kept ${report.keep} / write ${report.write.length} / drop ${report.dropCount} / unknown ${unknown.length} / conflict ${conflict.length}`,
118
126
  ];
119
127
  for (const e of report.write) lines.push(` ${e.source || e.action} ${e.name} — ${(e.description || "").slice(0, 60)}`);
120
128
  for (const n of report.drop) lines.push(` drop ${n}`);
121
129
  for (const n of unknown) lines.push(` ? ${n} ← surfaced, not dropped`);
122
130
  for (const n of empty) lines.push(` ~ ${n} ← empty stub skipped, not written`);
131
+ for (const n of conflict) lines.push(` ! ${n} ← added but exists locally, skipped — review manually`);
123
132
  if (orphanFiles.length) lines.push(` (unparsed old files left untouched: ${orphanFiles.length})`);
124
133
  console.log(lines.join("\n"));
125
134
  if (unknown.length) console.log("\n⚠ URGENT: unknown kept names above — surface to the user BEFORE applying; they may be renames the LLM invented.");
135
+ if (conflict.length) console.log("\n⚠ CONFLICT: added-but-exists above — old file kept, dream content discarded; merge by hand if the dream version carries new info.");
126
136
  if (!APPLY) { console.log("\ndry run — pass --apply to write"); process.exit(0); }
127
137
 
128
138
  // ─── write phase (--apply) ─────────────────────────────────────────────────────
package/hooks/common.mjs CHANGED
@@ -26,6 +26,7 @@ const DEF = {
26
26
  requestTimeout: 15,
27
27
  recentCount: 8,
28
28
  searchCount: 8,
29
+ globalCount: 3,
29
30
  maxContent: 3000,
30
31
  maxQueryLength: 200,
31
32
  logDir: join(HOME, ".config/cerebro/logs"),
@@ -75,6 +76,7 @@ function loadConfig() {
75
76
  requestTimeout: num("MEM_REQUEST_TIMEOUT", c.requestTimeoutMs ? c.requestTimeoutMs / 1000 : null, DEF.requestTimeout),
76
77
  recentCount: num("MEM_RECENT_COUNT", i.recentCount, DEF.recentCount),
77
78
  searchCount: num("MEM_SEARCH_COUNT", i.searchCount, DEF.searchCount),
79
+ globalCount: num("MEM_GLOBAL_COUNT", i.globalCount, DEF.globalCount),
78
80
  maxContent: num("MEM_MAX_CONTENT", ct.maxContentLength || ct.maxContentChars, DEF.maxContent),
79
81
  maxQueryLength: num("MEM_MAX_QUERY_LENGTH", ct.maxQueryLength, DEF.maxQueryLength),
80
82
  logDir: (process.env.MEM_LOG_DIR || lg.logDir || DEF.logDir).replace(/^~/, HOME),
@@ -499,7 +501,7 @@ export async function flushSessionIngest(transcriptPath, sessionId, timeoutSec =
499
501
  const agentId = process.env.OMEM_AGENT_ID || "claude-code";
500
502
 
501
503
  if (messages.length > 0) {
502
- const body = { messages, agent_id: agentId };
504
+ const body = { messages, agent_id: agentId, home_path: HOME };
503
505
  if (sessionId) body.session_id = sessionId;
504
506
  if (pn) body.project_name = pn;
505
507
  if (pp) body.project_path = pp;
@@ -570,12 +572,13 @@ export function truncateAtBoundary(text, maxLength) {
570
572
  }
571
573
 
572
574
  // GET /v1/memories/search — 单路语义搜索
573
- export async function searchMemories(query, limit, projectPath) {
575
+ export async function searchMemories(query, limit, projectPath, excludeGlobal) {
574
576
  limit = limit || config.searchCount;
575
577
  const safeQ = truncateQuery(query);
576
578
  if (!safeQ) return [];
577
579
  const params = new URLSearchParams({ q: safeQ, limit: String(limit) });
578
580
  if (projectPath) params.set("project_path", projectPath);
581
+ if (excludeGlobal) params.set("exclude_global", "1");
579
582
  try {
580
583
  const resp = await fetch(`${config.apiUrl}/v1/memories/search?${params}`, {
581
584
  headers: { "X-API-Key": config.apiKey, Accept: "application/json" },
@@ -589,18 +592,23 @@ export async function searchMemories(query, limit, projectPath) {
589
592
  }
590
593
 
591
594
  // buildMemoryInjection — 对标 opencode hooks.ts:246-329
592
- // 三路并发:profile + recent + search(query)。query 为空跳过 search。
595
+ // 四路并发:profile + global + recent + search(query)。query 为空跳过 search。
596
+ // 拍板(issue #3):项目 recent/search 路带 exclude_global(专区已单列,项目路不混全局);
597
+ // 全局专区 globalCount 条(SessionStart q 为空,走 list 按时间取最新全局记忆)。
593
598
  export async function buildMemoryInjection(query, projectPath, options = {}) {
594
599
  const profileEnabled = options.profileEnabled !== false;
595
600
  const recentEnabled = options.recentEnabled !== false;
601
+ const globalEnabled = options.globalEnabled !== false;
596
602
  const hdrs = { "X-API-Key": config.apiKey, Accept: "application/json" };
597
603
  const recentCount = config.recentCount;
598
604
  const searchCount = config.searchCount;
605
+ const globalCount = config.globalCount;
599
606
  const profileQs = projectPath ? `?project_path=${encodeURIComponent(projectPath)}` : "";
600
- const recentQs = `?limit=${recentCount}&offset=0&sort=updated_at&order=desc${projectPath ? `&project_path=${encodeURIComponent(projectPath)}` : ""}`;
607
+ const recentQs = `?limit=${recentCount}&offset=0&sort=updated_at&order=desc&exclude_global=1${projectPath ? `&project_path=${encodeURIComponent(projectPath)}` : ""}`;
608
+ const globalQs = `?limit=${globalCount}&offset=0&sort=updated_at&order=desc&global_only=1`;
601
609
  const safeQ = truncateQuery(query);
602
610
 
603
- const [profileResp, recentResp, searchResp] = await Promise.all([
611
+ const [profileResp, recentResp, globalResp, searchResp] = await Promise.all([
604
612
  profileEnabled
605
613
  ? fetch(`${config.apiUrl}/v2/profile/inject${profileQs}`, { headers: hdrs, signal: AbortSignal.timeout(config.profileTimeoutMs) })
606
614
  .then((r) => r.text()).catch(() => "")
@@ -609,8 +617,12 @@ export async function buildMemoryInjection(query, projectPath, options = {}) {
609
617
  ? fetch(`${config.apiUrl}/v1/memories${recentQs}`, { headers: hdrs, signal: AbortSignal.timeout(config.recentTimeoutMs) })
610
618
  .then((r) => (r.ok ? r.text() : null)).catch(() => null)
611
619
  : Promise.resolve(""),
620
+ globalEnabled
621
+ ? fetch(`${config.apiUrl}/v1/memories${globalQs}`, { headers: hdrs, signal: AbortSignal.timeout(config.recentTimeoutMs) })
622
+ .then((r) => (r.ok ? r.text() : null)).catch(() => null)
623
+ : Promise.resolve(""),
612
624
  safeQ
613
- ? fetch(`${config.apiUrl}/v1/memories/search?q=${encodeURIComponent(safeQ)}&limit=${searchCount}${projectPath ? `&project_path=${encodeURIComponent(projectPath)}` : ""}`, { headers: hdrs, signal: AbortSignal.timeout(5000) })
625
+ ? fetch(`${config.apiUrl}/v1/memories/search?q=${encodeURIComponent(safeQ)}&limit=${searchCount}&exclude_global=1${projectPath ? `&project_path=${encodeURIComponent(projectPath)}` : ""}`, { headers: hdrs, signal: AbortSignal.timeout(5000) })
614
626
  .then((r) => r.text()).catch(() => "")
615
627
  : Promise.resolve(""),
616
628
  ]);
@@ -634,6 +646,13 @@ export async function buildMemoryInjection(query, projectPath, options = {}) {
634
646
  } catch {}
635
647
  }
636
648
 
649
+ // parse global (最新全局记忆,跨项目)
650
+ let globalMemories = [];
651
+ try {
652
+ const gd = JSON.parse(globalResp);
653
+ if (gd && !gd.error) globalMemories = gd.memories || [];
654
+ } catch {}
655
+
637
656
  // parse search
638
657
  let searchResults = [];
639
658
  try {
@@ -650,6 +669,16 @@ export async function buildMemoryInjection(query, projectPath, options = {}) {
650
669
  }
651
670
 
652
671
  const seenIds = new Set();
672
+ if (globalMemories.length > 0) {
673
+ sections.push("## Global Memories");
674
+ for (const m of globalMemories) {
675
+ if (m.id) seenIds.add(m.id);
676
+ const age = formatRelativeAge(m.updated_at || m.created_at);
677
+ sections.push(`- (${age}) ${m.content || ""}`);
678
+ }
679
+ sections.push("");
680
+ }
681
+
653
682
  if (projectMemories.length > 0) {
654
683
  sections.push("## Recent Project Activity");
655
684
  for (const m of projectMemories) {
@@ -682,6 +711,7 @@ export async function buildMemoryInjection(query, projectPath, options = {}) {
682
711
  return {
683
712
  text,
684
713
  profileCount: profileContent ? 1 : 0,
714
+ globalCount: globalMemories.length,
685
715
  projectMemoryCount: projectMemories.length,
686
716
  searchCount: dedupedResults.length,
687
717
  recentFailed,
package/hooks/common.sh CHANGED
File without changes
package/hooks/dream.mjs CHANGED
File without changes
File without changes
package/hooks/hooks.json CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -158,9 +158,9 @@ if (startSource === "clear") {
158
158
  await postRecallEvent({
159
159
  sessionId: sid,
160
160
  recallType: "session_start",
161
- queryText: `Session Start · ${injection.projectMemoryCount} memories · ${injection.profileCount > 0 ? "profile" : "no profile"}`,
161
+ queryText: `Session Start · ${injection.globalCount} global · ${injection.projectMemoryCount} project · ${injection.profileCount > 0 ? "profile" : "no profile"}`,
162
162
  profileInjected: injection.profileCount > 0,
163
- keptCount: injection.projectMemoryCount,
163
+ keptCount: injection.globalCount + injection.projectMemoryCount,
164
164
  injectedContent: out,
165
165
  failureReason: injection.recentFailed ? "recent fetch failed/timeout" : "",
166
166
  });
package/hooks/stop.mjs CHANGED
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro-claude-code",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Persistent memory for Claude Code — memories survive across sessions, projects, and machines",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
3
3
  import { spawn } from "node:child_process";
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
6
8
 
7
9
  const HOOKS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks");
8
10
 
@@ -109,3 +111,38 @@ describe("session-end.mjs", () => {
109
111
  assert.deepEqual(out, {});
110
112
  });
111
113
  });
114
+
115
+ describe("apply-dream.mjs", () => {
116
+ test("added onto existing local file is a conflict, not an overwrite", async () => {
117
+ const tmp = mkdtempSync(join(tmpdir(), "apply-dream-"));
118
+ try {
119
+ const memDir = join(tmp, "memory");
120
+ mkdirSync(memDir);
121
+ const existing = "---\nname: cc-x\ndescription: hand-written\nmetadata:\n type: feedback\n---\n\nprecise body\n";
122
+ writeFileSync(join(memDir, "cc-x.md"), existing);
123
+ writeFileSync(join(memDir, "MEMORY.md"), "- [cc-x](cc-x.md) — hand-written\n");
124
+ const outDir = join(tmp, "dream", "output");
125
+ mkdirSync(outDir, { recursive: true });
126
+ writeFileSync(join(outDir, "20260820.json"), JSON.stringify({ entries: [
127
+ { name: "cc-x", action: "added", body: "condensed stub rewrite" }, // mislabeled rewrite
128
+ { name: "cc-new", action: "added", body: "genuinely new" }, // real addition
129
+ ] }));
130
+
131
+ const res = await new Promise((resolve) => {
132
+ const child = spawn(process.execPath, [join(HOOKS_DIR, "apply-dream.mjs"), "--apply"], {
133
+ env: { ...process.env, OMEM_DREAM_DIR: join(tmp, "dream"), OMEM_DREAM_MEMORY_DIR: memDir },
134
+ });
135
+ let stdout = "";
136
+ child.stdout.on("data", (d) => (stdout += d));
137
+ child.on("close", (code) => resolve({ stdout, code }));
138
+ });
139
+
140
+ assert.ok(res.stdout.includes("conflict 1"), `expected one conflict, got: ${res.stdout}`);
141
+ assert.ok(res.stdout.includes("! cc-x"), "conflicting name must be listed");
142
+ assert.equal(readFileSync(join(memDir, "cc-x.md"), "utf8"), existing, "existing file must stay untouched");
143
+ assert.ok(readFileSync(join(memDir, "cc-new.md"), "utf8").includes("genuinely new"), "genuinely new entry must be written");
144
+ } finally {
145
+ rmSync(tmp, { recursive: true, force: true });
146
+ }
147
+ });
148
+ });
File without changes
File without changes
File without changes
File without changes
package/web/favicon.svg CHANGED
File without changes
package/web/icons.svg CHANGED
File without changes
package/web/index.html CHANGED
File without changes