@mingxy/cerebro-claude-code 0.3.10 → 0.3.12

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +0 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +0 -0
  4. package/README.md +0 -0
  5. package/config.json +0 -0
  6. package/hooks/apply-dream.mjs +0 -0
  7. package/hooks/common.mjs +63 -17
  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 +3 -3
  16. package/hooks/session-start.mjs +14 -20
  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 +41 -2
  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 +0 -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-BITUpTtU.js +0 -0
  36. package/web/assets/index-DVguCuEA.css +0 -0
  37. package/web/assets/index-zOJZZn-i.js +0 -0
  38. package/web/favicon.svg +0 -0
  39. package/web/icons.svg +0 -0
  40. 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.10",
4
+ "version": "0.3.12",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
7
7
  "email": "hi@cerebro.dev"
package/.mcp.json CHANGED
File without changes
package/README.md CHANGED
File without changes
package/config.json CHANGED
File without changes
File without changes
package/hooks/common.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Ported from common.sh — no bash/curl/python3 dependency. Pure Node.
3
3
  // Config cascade: env > ~/.config/cerebro/config.json > builtin defaults
4
4
  import { createHash } from "node:crypto";
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, copyFileSync } from "node:fs";
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, copyFileSync, readdirSync } from "node:fs";
6
6
  import { join, dirname, resolve } from "node:path";
7
7
  import { execSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
@@ -33,6 +33,7 @@ const DEF = {
33
33
  logEnabled: true,
34
34
  profileTimeoutMs: 2000,
35
35
  recentTimeoutMs: 6000,
36
+ webGraceMs: 60000,
36
37
  };
37
38
 
38
39
  // ─── Config cascade ──────────────────────────────────────────────────────────
@@ -62,6 +63,7 @@ function loadConfig() {
62
63
  const i = cfg.injection || {};
63
64
  const ct = cfg.content || {};
64
65
  const lg = cfg.logging || {};
66
+ const w = cfg.web || {};
65
67
 
66
68
  const num = (env, cfgVal, def) => {
67
69
  const v = process.env[env];
@@ -87,6 +89,7 @@ function loadConfig() {
87
89
  : DEF.logEnabled,
88
90
  profileTimeoutMs: i.profileTimeoutMs || DEF.profileTimeoutMs,
89
91
  recentTimeoutMs: i.recentTimeoutMs || DEF.recentTimeoutMs,
92
+ webGraceMs: num("OMEM_WEB_GRACE_MS", w.graceMs, DEF.webGraceMs),
90
93
  };
91
94
  }
92
95
 
@@ -220,31 +223,74 @@ function _log(level, msg) {
220
223
  } catch {}
221
224
  }
222
225
 
223
- // ─── Web server refcount (multi-session lifecycle) ───────────────────────────
224
- const REFCOUNT_FILE = join(HOME, ".config/cerebro/web-server.refcount");
226
+ // ─── Web server liveness (truth = OS process table) ──────────────────────────
227
+ // Refcount bookkeeping leaked to 169 and never hit zero — counting events is
228
+ // unreliable (killed terminals never decrement). Instead: each CC session
229
+ // drops sessions/<sid>.live holding its CC main pid; anyone interested sweeps
230
+ // the dir with kill(pid, 0) — dead pids get swept, no ledger to rot.
231
+ const SESSIONS_DIR = join(HOME, ".config/cerebro/sessions");
225
232
  const WEB_PID_FILE = join(HOME, ".config/cerebro/web-server.pid");
226
233
 
227
- export function refCountInc() {
234
+ // CC runs hooks through a bash wrapper — process.ppid is the wrapper, which
235
+ // dies the moment the hook exits. Climb past shells to the CC main process;
236
+ // non-Linux (no /proc) or unclimbable chain falls back to the old behavior.
237
+ function ccMainPid() {
238
+ let pid = process.ppid;
239
+ for (let i = 0; i < 4 && pid > 1; i++) {
240
+ try {
241
+ const comm = readFileSync(`/proc/${pid}/comm`, "utf-8").trim();
242
+ if (!/^(bash|sh|dash|zsh)$/.test(comm)) return pid;
243
+ const m = readFileSync(`/proc/${pid}/status`, "utf-8").match(/^PPid:\s+(\d+)/m);
244
+ if (!m) break;
245
+ pid = parseInt(m[1], 10);
246
+ } catch { break; }
247
+ }
248
+ return process.ppid;
249
+ }
250
+
251
+ export function writeLive(sid) {
252
+ if (!sid) return;
228
253
  try {
229
- const n = existsSync(REFCOUNT_FILE) ? parseInt(readFileSync(REFCOUNT_FILE, "utf-8").trim(), 10) || 0 : 0;
230
- writeFileSync(REFCOUNT_FILE, String(n + 1));
254
+ mkdirSync(SESSIONS_DIR, { recursive: true });
255
+ writeFileSync(join(SESSIONS_DIR, `${sid}.live`), String(ccMainPid()));
231
256
  } catch {}
232
257
  }
233
258
 
234
- export function refCountDec() {
259
+ export function removeLive(sid) {
260
+ if (!sid) return;
261
+ try { unlinkSync(join(SESSIONS_DIR, `${sid}.live`)); } catch {}
262
+ }
263
+
264
+ export function countLiveCC() {
265
+ let alive = 0;
235
266
  try {
236
- let n = existsSync(REFCOUNT_FILE) ? parseInt(readFileSync(REFCOUNT_FILE, "utf-8").trim(), 10) || 0 : 0;
237
- n = Math.max(0, n - 1);
238
- if (n === 0) {
267
+ for (const f of readdirSync(SESSIONS_DIR)) {
268
+ if (!f.endsWith(".live")) continue;
269
+ const p = join(SESSIONS_DIR, f);
239
270
  try {
240
- const pid = parseInt(readFileSync(WEB_PID_FILE, "utf-8").trim(), 10);
241
- if (pid) process.kill(pid, "SIGTERM");
242
- } catch {}
243
- try { unlinkSync(WEB_PID_FILE); } catch {}
244
- try { unlinkSync(REFCOUNT_FILE); } catch {}
245
- } else {
246
- writeFileSync(REFCOUNT_FILE, String(n));
271
+ process.kill(parseInt(readFileSync(p, "utf-8").trim(), 10), 0); // throws if dead
272
+ alive++;
273
+ } catch (e) {
274
+ // EPERM = process exists but is root's — still alive; only ESRCH = dead
275
+ if (e && e.code === "EPERM") alive++;
276
+ else { try { unlinkSync(p); } catch {} } // stale — sweep
277
+ }
247
278
  }
279
+ } catch {} // dir missing = no session ever
280
+ return alive;
281
+ }
282
+
283
+ // Kill a stale web-server daemon so a fresh one can bind (rebirth on every
284
+ // session start — no version sniffing). pid verified via /proc cmdline to
285
+ // survive pid reuse; non-Linux (no /proc) degrades to no-op.
286
+ export function killStaleWebServer() {
287
+ try {
288
+ const pid = parseInt(readFileSync(WEB_PID_FILE, "utf-8").trim(), 10);
289
+ if (!pid || pid === process.pid) return;
290
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
291
+ if (!cmdline.includes("web-server.mjs")) return; // not ours — don't touch
292
+ process.kill(pid, "SIGTERM");
293
+ setTimeout(() => { try { process.kill(pid, "SIGKILL"); } catch {} }, 500).unref();
248
294
  } catch {}
249
295
  }
250
296
 
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
@@ -5,7 +5,7 @@
5
5
  // Fix: spawn detached child process, parent exits immediately.
6
6
  import { spawn } from "node:child_process";
7
7
  import { join } from "node:path";
8
- import { config, PLUGIN_ROOT, refCountDec, parseStdinJSON, emit, logDebug, writePendingClearFlush } from "./common.mjs";
8
+ import { config, PLUGIN_ROOT, removeLive, parseStdinJSON, emit, logDebug, writePendingClearFlush } from "./common.mjs";
9
9
 
10
10
  if (!config.apiKey) { emit({}); process.exit(0); }
11
11
 
@@ -20,7 +20,7 @@ logDebug(`session-end: sid=${sid} reason=${reason} tp=${tp ? tp.slice(-40) : "EM
20
20
  // flush to it so the result lands in that hook's toast (detached flush is mute).
21
21
  if (reason === "clear" && tp && sid) {
22
22
  writePendingClearFlush(tp, sid);
23
- refCountDec();
23
+ removeLive(sid);
24
24
  emit({});
25
25
  process.exit(0);
26
26
  }
@@ -38,7 +38,7 @@ if (tp && sid) {
38
38
  } catch {}
39
39
  }
40
40
 
41
- refCountDec();
41
+ removeLive(sid);
42
42
  emit({
43
43
  systemMessage: `🧠 Cerebro · Session flush in background`,
44
44
  });
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { join } from "node:path";
5
5
  import { readFileSync } from "node:fs";
6
6
  import {
7
- config, PLUGIN_ROOT, PLUGIN_VERSION, detectProjectPath, parseStdinJSON, emit, buildMemoryInjection, postRecallEvent, refCountInc, readCompactResult, readPendingClearFlush, clearPendingClearFlush, flushSessionIngest, injectionConfig,
7
+ config, PLUGIN_ROOT, PLUGIN_VERSION, detectProjectPath, parseStdinJSON, emit, buildMemoryInjection, postRecallEvent, writeLive, killStaleWebServer, readCompactResult, readPendingClearFlush, clearPendingClearFlush, flushSessionIngest, injectionConfig,
8
8
  } from "./common.mjs";
9
9
  import { judgeMaterial, readState, writeStateForReport, fetchOrphanResult } from "./dream.mjs";
10
10
 
@@ -61,24 +61,18 @@ const input = parseStdinJSON();
61
61
  const sid = input.session_id || "";
62
62
  const startSource = input.source || ""; // "startup" | "resume" | "clear" | "compact"
63
63
 
64
- // ─── web server 拉起(probe + detached spawn,跨平台)─────────────────────────
65
- const webPort = process.env.OMEM_LOCAL_PORT || "5212";
64
+ // ─── web server 换血(杀旧起新,无条件——升级即生效,无版本探测分支)──────────
65
+ killStaleWebServer();
66
+ await new Promise((r) => setTimeout(r, 700)); // old daemon TERM→KILL, port frees
66
67
  try {
67
- const resp = await fetch(`http://127.0.0.1:${webPort}/health`, {
68
- signal: AbortSignal.timeout(1000),
69
- });
70
- if (!resp.ok) throw new Error("not healthy");
71
- } catch {
72
- try {
73
- const child = spawn(
74
- process.execPath,
75
- [join(PLUGIN_ROOT, "scripts", "web-server.mjs")],
76
- { detached: true, stdio: "ignore", env: { ...process.env } },
77
- );
78
- child.unref();
79
- } catch {}
80
- }
81
- refCountInc();
68
+ const child = spawn(
69
+ process.execPath,
70
+ [join(PLUGIN_ROOT, "scripts", "web-server.mjs")],
71
+ { detached: true, stdio: "ignore", env: { ...process.env, OMEM_WEB_GRACE_MS: String(config.webGraceMs) } },
72
+ );
73
+ child.unref();
74
+ } catch {}
75
+ writeLive(sid);
82
76
 
83
77
  // ─── API key 检查 ────────────────────────────────────────────────────────────
84
78
  if (!config.apiKey) {
@@ -119,8 +113,8 @@ out = out.replace("[CEREBRO-MEMORY]", `[CEREBRO-MEMORY]\n${timeLine}`);
119
113
  // CEREBRO-STATUS 通过 systemMessage 显示给用户(Q2: toast 替代方案)
120
114
  const memCount = injection.projectMemoryCount + injection.searchCount;
121
115
  let statusMsg = injection.recentFailed
122
- ? `🧠 Cerebro v${PLUGIN_VERSION} · Recent ✗ (timeout) · Profile ${injection.profileCount > 0 ? "✓" : "✗"}`
123
- : `🧠 Cerebro v${PLUGIN_VERSION} · Connected · ${memCount} memories · Profile ${injection.profileCount > 0 ? "✓" : "✗"}`;
116
+ ? `🧠 Cerebro v${PLUGIN_VERSION} · Recent ✗ (timeout) · Global ${injection.globalCount} · Profile ${injection.profileCount > 0 ? "✓" : "✗"}`
117
+ : `🧠 Cerebro v${PLUGIN_VERSION} · Connected · ${memCount} memories · Global ${injection.globalCount} · Profile ${injection.profileCount > 0 ? "✓" : "✗"}`;
124
118
 
125
119
  // After compact, PostCompact toast gets overridden by SessionStart:compact toast.
126
120
  // Merge PostCompact ingest result into this toast so user sees it.
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.10",
3
+ "version": "0.3.12",
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
@@ -128,5 +128,44 @@ server.listen(PORT, "127.0.0.1", () => {
128
128
  console.log(`[cerebro web-server] serving ${WEB_DIR} at http://localhost:${PORT} (pid=${process.pid})`);
129
129
  });
130
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); }));
131
+ // ── CC-liveness watchdog ─────────────────────────────────────────────────────
132
+ // Truth = OS process table: sessions/<sid>.live maps each CC session to its
133
+ // CC main pid. Sweep on an interval, kill(pid, 0) each, sweep stale ones.
134
+ // Zero CC sessions alive → count down the grace window → self-exit. Survives
135
+ // killed terminals (no SessionEnd needed) — refcount bookkeeping is gone.
136
+ const SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "", ".config/cerebro/sessions");
137
+ const GRACE_MS = parseInt(process.env.OMEM_WEB_GRACE_MS || "", 10) || 60_000;
138
+ const WATCH_MS = parseInt(process.env.OMEM_WEB_WATCH_MS || "", 10) || 60_000;
139
+ let zeroSince = 0; // 0 = CC alive (or unknown)
140
+
141
+ setInterval(() => {
142
+ let alive = 0;
143
+ try {
144
+ for (const f of fs.readdirSync(SESSIONS_DIR)) {
145
+ if (!f.endsWith(".live")) continue;
146
+ const fp = path.join(SESSIONS_DIR, f);
147
+ try {
148
+ process.kill(parseInt(fs.readFileSync(fp, "utf-8").trim(), 10), 0);
149
+ alive++;
150
+ } catch (e) {
151
+ if (e && e.code === "EPERM") alive++; // exists, root-owned — alive
152
+ else { try { fs.unlinkSync(fp); } catch {} }
153
+ }
154
+ }
155
+ } catch {} // dir missing = no CC session ever
156
+ if (alive > 0) { zeroSince = 0; return; }
157
+ if (!zeroSince) zeroSince = Date.now();
158
+ if (Date.now() - zeroSince >= GRACE_MS) {
159
+ console.log(`[cerebro web-server] no CC sessions alive for ${GRACE_MS / 1000}s, self-exiting`);
160
+ try { fs.unlinkSync(PID_FILE); } catch {}
161
+ process.exit(0);
162
+ }
163
+ }, WATCH_MS);
164
+
165
+ const shutdown = () => {
166
+ try { server.closeAllConnections(); } catch {} // Node ≥18.2; old node degrades
167
+ server.close(() => { try { unlinkSync(PID_FILE); } catch {} process.exit(0); });
168
+ setTimeout(() => process.exit(0), 1000).unref();
169
+ };
170
+ process.on("SIGTERM", shutdown);
171
+ process.on("SIGINT", shutdown);
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
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