@mingxy/cerebro-claude-code 0.3.9 → 0.3.11

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.
@@ -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.9",
4
+ "version": "0.3.11",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
7
7
  "email": "hi@cerebro.dev"
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,57 @@ 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
+ export function writeLive(sid) {
235
+ if (!sid) return;
228
236
  try {
229
- const n = existsSync(REFCOUNT_FILE) ? parseInt(readFileSync(REFCOUNT_FILE, "utf-8").trim(), 10) || 0 : 0;
230
- writeFileSync(REFCOUNT_FILE, String(n + 1));
237
+ mkdirSync(SESSIONS_DIR, { recursive: true });
238
+ writeFileSync(join(SESSIONS_DIR, `${sid}.live`), String(process.ppid));
231
239
  } catch {}
232
240
  }
233
241
 
234
- export function refCountDec() {
242
+ export function removeLive(sid) {
243
+ if (!sid) return;
244
+ try { unlinkSync(join(SESSIONS_DIR, `${sid}.live`)); } catch {}
245
+ }
246
+
247
+ export function countLiveCC() {
248
+ let alive = 0;
235
249
  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) {
250
+ for (const f of readdirSync(SESSIONS_DIR)) {
251
+ if (!f.endsWith(".live")) continue;
252
+ const p = join(SESSIONS_DIR, f);
239
253
  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));
254
+ process.kill(parseInt(readFileSync(p, "utf-8").trim(), 10), 0); // throws if dead
255
+ alive++;
256
+ } catch (e) {
257
+ // EPERM = process exists but is root's — still alive; only ESRCH = dead
258
+ if (e && e.code === "EPERM") alive++;
259
+ else { try { unlinkSync(p); } catch {} } // stale — sweep
260
+ }
247
261
  }
262
+ } catch {} // dir missing = no session ever
263
+ return alive;
264
+ }
265
+
266
+ // Kill a stale web-server daemon so a fresh one can bind (rebirth on every
267
+ // session start — no version sniffing). pid verified via /proc cmdline to
268
+ // survive pid reuse; non-Linux (no /proc) degrades to no-op.
269
+ export function killStaleWebServer() {
270
+ try {
271
+ const pid = parseInt(readFileSync(WEB_PID_FILE, "utf-8").trim(), 10);
272
+ if (!pid || pid === process.pid) return;
273
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
274
+ if (!cmdline.includes("web-server.mjs")) return; // not ours — don't touch
275
+ process.kill(pid, "SIGTERM");
276
+ setTimeout(() => { try { process.kill(pid, "SIGKILL"); } catch {} }, 500).unref();
248
277
  } catch {}
249
278
  }
250
279
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro-claude-code",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Persistent memory for Claude Code — memories survive across sessions, projects, and machines",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
@@ -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);