@mingxy/cerebro-claude-code 0.3.4 → 0.3.5

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.4",
4
+ "version": "0.3.5",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
7
7
  "email": "hi@cerebro.dev"
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // apply-dream: merge a dream output archive into MEMORY_DIR under the merge contract:
3
+ // join key = name (the only anchor; server prompt forces verbatim copy for kept)
4
+ // kept → old archive wins verbatim; every LLM-provided field except name is ignored
5
+ // unknown kept name → surfaced as `unknown`, NEVER silently dropped (that is memory evaporation)
6
+ // dropped → removal candidate, listed for review; applied only with --apply
7
+ // merged/updated/added → LLM version wins (content is allowed to change there)
8
+ // stats.total still counts kept entries — the ledger is the server's, not ours to re-derive
9
+ //
10
+ // Read-only review by default (prints the diff); `--apply` writes after user approval.
11
+ // Usage:
12
+ // node apply-dream.mjs # review diff for the unconsumed output
13
+ // node apply-dream.mjs --apply # write files + rebuild MEMORY.md index lines
14
+ import { existsSync, readFileSync, writeFileSync, readdirSync, rmSync, mkdirSync, renameSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ const HOME = process.env.HOME || "/home/dongx";
18
+ const DREAM_DIR = process.env.OMEM_DREAM_DIR || join(HOME, ".cache", "cerebro", "dream");
19
+ const OUT_DIR = join(DREAM_DIR, "output");
20
+ const STATE = join(DREAM_DIR, "state.json");
21
+ const MEMORY_DIR = process.env.OMEM_DREAM_MEMORY_DIR || join(HOME, ".claude", "projects", "-home-dongx", "memory");
22
+ const INDEX = join(MEMORY_DIR, "MEMORY.md");
23
+ const APPLY = process.argv.includes("--apply");
24
+
25
+ // ─── old archive: parse every memory file into {name, description, type, body, raw} ──
26
+ function parseMemoryFile(path) {
27
+ const raw = readFileSync(path, "utf8");
28
+ const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
29
+ if (!m) return { name: null, raw };
30
+ const fm = {};
31
+ for (const line of m[1].split("\n")) {
32
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
33
+ if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, "");
34
+ }
35
+ return { name: fm.name || null, description: fm.description || "", type: (fm.metadata || "").replace(/.*type:\s*/, "").trim() || "", body: m[2], fmText: m[1], raw };
36
+ }
37
+
38
+ const oldFiles = new Map(); // name → {path, parsed}
39
+ const byBase = new Map(); // basename → same objects, second-chance join by file
40
+ const alias = new Map(); // MEMORY.md link text (often Chinese) → name
41
+ const orphanFiles = []; // unparseable / no name — never touched, surfaced
42
+ for (const f of readdirSync(MEMORY_DIR)) {
43
+ if (!f.endsWith(".md") || f === "MEMORY.md") continue;
44
+ const p = { path: join(MEMORY_DIR, f), parsed: parseMemoryFile(join(MEMORY_DIR, f)) };
45
+ byBase.set(f, p);
46
+ if (p.parsed.name) oldFiles.set(p.parsed.name, p); else orphanFiles.push(p.path);
47
+ }
48
+ // the dream LLM sees MEMORY.md too and sometimes echoes the Chinese link text
49
+ // instead of the frontmatter slug — join those via the index line
50
+ {
51
+ const idxRaw = existsSync(INDEX) ? readFileSync(INDEX, "utf8") : "";
52
+ for (const m of idxRaw.matchAll(/\[([^\]]+)\]\(([^)]+\.md)\)/g)) {
53
+ const hit = byBase.get(m[2].replace(/.*\//, ""));
54
+ if (hit?.parsed.name) alias.set(m[1], hit.parsed.name);
55
+ }
56
+ }
57
+
58
+ // ─── pick the archive: unconsumed output from state, else newest output file ──
59
+ let archivePath = null;
60
+ try {
61
+ const st = JSON.parse(readFileSync(STATE, "utf8"));
62
+ if (st.output && existsSync(st.output)) archivePath = st.output;
63
+ } catch {}
64
+ if (!archivePath && existsSync(OUT_DIR)) {
65
+ const files = readdirSync(OUT_DIR).filter((f) => f.endsWith(".json")).sort();
66
+ if (files.length) archivePath = join(OUT_DIR, files[files.length - 1]);
67
+ }
68
+ if (!archivePath) { console.error("apply-dream: no dream output found"); process.exit(1); }
69
+ const entries = JSON.parse(readFileSync(archivePath, "utf8")).entries || [];
70
+
71
+ // ─── merge ────────────────────────────────────────────────────────────────────
72
+ const unknown = []; // kept names missing from the old archive — LLM renamed, memory at risk
73
+ const report = { keep: 0, write: [], drop: [], dropCount: 0 };
74
+ for (let e of entries) {
75
+ // normalize BEFORE any branch: an LLM-emitted Chinese name that the MEMORY.md
76
+ // index can resolve must land on the old file, or updated entries fork duplicates
77
+ if (!oldFiles.has(e.name) && alias.has(e.name)) e = { ...e, name: alias.get(e.name) };
78
+ const act = e.source || e.action || "";
79
+ if (e.source === "kept" || act === "kept") {
80
+ if (!oldFiles.has(e.name)) { unknown.push(e.name); continue; }
81
+ report.keep++; // old archive wins; nothing to do, not even a rewrite
82
+ } else if (act === "dropped") {
83
+ report.drop.push(e.name);
84
+ report.dropCount++;
85
+ } else if (act === "added" || act === "merged" || act === "updated") {
86
+ report.write.push(e); // LLM content is authoritative for these actions
87
+ } else {
88
+ unknown.push(`${e.name} (action=${act || "?"})`);
89
+ }
90
+ }
91
+
92
+ // ─── review output ────────────────────────────────────────────────────────────
93
+ const lines = [
94
+ `apply-dream review · archive ${archivePath}`,
95
+ `kept ${report.keep} / write ${report.write.length} / drop ${report.dropCount} / unknown ${unknown.length}`,
96
+ ];
97
+ for (const e of report.write) lines.push(` ${e.source || e.action} ${e.name} — ${(e.description || "").slice(0, 60)}`);
98
+ for (const n of report.drop) lines.push(` drop ${n}`);
99
+ for (const n of unknown) lines.push(` ? ${n} ← surfaced, not dropped`);
100
+ if (orphanFiles.length) lines.push(` (unparsed old files left untouched: ${orphanFiles.length})`);
101
+ console.log(lines.join("\n"));
102
+ if (unknown.length) console.log("\n⚠ URGENT: unknown kept names above — surface to the user BEFORE applying; they may be renames the LLM invented.");
103
+ if (!APPLY) { console.log("\ndry run — pass --apply to write"); process.exit(0); }
104
+
105
+ // ─── write phase (--apply) ─────────────────────────────────────────────────────
106
+ mkdirSync(MEMORY_DIR, { recursive: true });
107
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "memory";
108
+ for (const e of report.write) {
109
+ const old = oldFiles.get(e.name);
110
+ const path = old?.path || join(MEMORY_DIR, `${slug(e.name)}.md`);
111
+ const type = e.type || old?.parsed.type || "project";
112
+ const out = [
113
+ "---",
114
+ `name: ${e.name}`,
115
+ ...(e.description ? [`description: ${e.description.replace(/\n/g, " ")}`] : []),
116
+ "metadata:",
117
+ ` type: ${type}`,
118
+ "---",
119
+ "",
120
+ (e.body || "").trim(),
121
+ "",
122
+ ].join("\n");
123
+ const tmp = path + ".tmp";
124
+ writeFileSync(tmp, out); renameSync(tmp, path); // atomic, same convention as state.json
125
+ }
126
+ for (const n of report.drop) {
127
+ const old = oldFiles.get(n);
128
+ if (old) rmSync(old.path);
129
+ }
130
+
131
+ // MEMORY.md index: keep human-written lines by name, append new, drop removed
132
+ const idx = existsSync(INDEX) ? readFileSync(INDEX, "utf8").split("\n") : ["# MEMORY.md — 本地记忆索引", ""];
133
+ const keptNames = new Set([...entries.filter((e) => e.source !== "kept" || e.action !== "dropped" && oldFiles.has(e.name) || true).map((e) => e.name)]);
134
+ const outLines = idx.filter((l) => {
135
+ const m = l.match(/\]\(([^)]+\.md)\)/);
136
+ if (!m) return true; // headers, blanks, non-link lines stay
137
+ const base = m[1].replace(/.*\//, "");
138
+ const hit = report.write.find((e) => (oldFiles.get(e.name)?.path || "").endsWith(base));
139
+ if (hit) return keptNames.has(hit.name);
140
+ const drop = report.drop.find((n) => (oldFiles.get(n)?.path || "").endsWith(base));
141
+ return !drop;
142
+ });
143
+ for (const e of report.write) {
144
+ if (outLines.some((l) => l.includes(`(${slug(e.name)}.md)`))) continue;
145
+ outLines.push(`- [${e.name}](${slug(e.name)}.md) — ${(e.description || "").slice(0, 40)}`);
146
+ }
147
+ writeFileSync(INDEX, outLines.join("\n").replace(/\n{3,}/g, "\n\n") + "\n");
148
+ console.log(`\napplied: ${report.write.length} written, ${report.dropCount} dropped, index rebuilt`);
package/hooks/dream.mjs CHANGED
@@ -23,6 +23,7 @@ const DREAM_DIR = process.env.OMEM_DREAM_DIR || join(HOME, ".cache", "cerebro",
23
23
  const OUT_DIR = join(DREAM_DIR, "output");
24
24
  const STATE = join(DREAM_DIR, "state.json");
25
25
  const LOCK = join(DREAM_DIR, "trigger.lock");
26
+ const CONF = join(DREAM_DIR, "config.json");
26
27
  // ponytail: hardcodes the home-project memory dir as the dream subject; DREAM_MEMORY_DIR
27
28
  // escapes hatch for other projects if per-project dreams ever matter
28
29
  const MEMORY_DIR = process.env.OMEM_DREAM_MEMORY_DIR || join(HOME, ".claude", "projects", "-home-dongx", "memory");
@@ -39,6 +40,14 @@ const LOCK_TTL_MS = 15 * 60 * 1000; // > 600s server job budget
39
40
  const POLL_INTERVAL_MS = 2000;
40
41
  const POLL_BUDGET_MS = 660 * 1000; // > 600s server timeout (ADR-3)
41
42
 
43
+ // ─── runtime config: on/off switch + badge TTL ───────────────────────────────
44
+ // Absent file = defaults (on): fresh install dreams without any setup.
45
+ const DEFAULT_CONF = { enabled: true, badge_ttl_secs: 3600 };
46
+ export function readDreamConfig() {
47
+ try { return { ...DEFAULT_CONF, ...JSON.parse(readFileSync(CONF, "utf8")) }; }
48
+ catch { return { ...DEFAULT_CONF }; }
49
+ }
50
+
42
51
  // ─── state helpers (tmp+rename atomic) ───────────────────────────────────────
43
52
  export function readState() {
44
53
  try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return null; }
@@ -74,7 +83,7 @@ function collectMemory() {
74
83
  }
75
84
 
76
85
  // ─── food: sessions (mechanical extraction, no LLM in hooks) ─────────────────
77
- function listTranscripts(sinceMs) {
86
+ function listTranscripts() {
78
87
  const out = [];
79
88
  for (const proj of readdirSync(PROJECTS_DIR)) {
80
89
  const dir = join(PROJECTS_DIR, proj);
@@ -114,17 +123,40 @@ function extractTranscript(path) {
114
123
  // ─── rhythm judge ────────────────────────────────────────────────────────────
115
124
  export function judgeMaterial(state) {
116
125
  const last = state?.last_dream_at ? Date.parse(state.last_dream_at) : 0;
117
- const sinceMs = last || 0;
118
- const newer = listTranscripts(sinceMs).filter((t) => t.mtime > sinceMs && t.mtime > last);
126
+ const newer = listTranscripts().filter((t) => t.mtime > last);
119
127
  const age = Date.now() - last;
120
128
  const enoughTime = age >= MIN_INTERVAL_MS;
121
129
  const fallback = last > 0 && age >= FALLBACK_MS;
122
- return { ok: newer.length >= MIN_NEW_SESSIONS && (enoughTime || !last) || fallback, since: last ? new Date(last).toISOString() : null, count: newer.length };
130
+ const remaining_ms = last ? Math.max(0, MIN_INTERVAL_MS - age) : null;
131
+ return { ok: newer.length >= MIN_NEW_SESSIONS && (enoughTime || !last) || fallback, since: last ? new Date(last).toISOString() : null, count: newer.length, remaining_ms };
132
+ }
133
+
134
+ // ─── statusline badge: all state→label/color decisions live here, bash renders ─
135
+ // off grey / run orange (zombie run >30min falls through) / fail red until TTL /
136
+ // rdy green (gates met) / accumulating `[n/2]·3h 41m` (needs n sessions + time).
137
+ const RUN_ZOMBIE_MS = 30 * 60 * 1000; // > lock TTL 15min + poll 11min: live run can't outlive it
138
+ function fmtRemain(ms) {
139
+ if (ms == null || ms <= 0) return "";
140
+ const m = Math.ceil(ms / 60000); // entry guard ⇒ m ≥ 1
141
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
142
+ }
143
+ export function badgeLine() {
144
+ const conf = readDreamConfig();
145
+ if (!conf.enabled) return { text: "cerebro dream off", color: 90 };
146
+ const st = readState();
147
+ const age = st?.updated_at ? Date.now() - Date.parse(st.updated_at) : Infinity;
148
+ if (st?.phase === "run" && age < RUN_ZOMBIE_MS) return { text: "cerebro dream run", color: 208 };
149
+ if (st?.phase === "fail" && age < conf.badge_ttl_secs * 1000) return { text: "cerebro dream fail", color: 196 };
150
+ const judge = judgeMaterial(st);
151
+ if (judge.ok) return { text: "cerebro dream rdy", color: 71 };
152
+ const rem = fmtRemain(judge.remaining_ms);
153
+ return { text: `cerebro dream [${Math.min(judge.count, MIN_NEW_SESSIONS)}/${MIN_NEW_SESSIONS}]${rem ? "·" + rem : ""}`, color: 250 };
123
154
  }
124
155
 
125
156
  // ─── detached main: trigger + poll + persist ─────────────────────────────────
126
157
  export async function runDream() {
127
158
  if (!config.apiKey) return;
159
+ if (!readDreamConfig().enabled) { logDebug("dream: disabled by config"); return; }
128
160
  if (!acquireLock()) { logDebug("dream: lock held, another window is dreaming"); return; }
129
161
  try {
130
162
  const prev = readState();
@@ -132,7 +164,7 @@ export async function runDream() {
132
164
  if (!judge.ok) { logDebug(`dream: not enough material (new=${judge.count})`); return; }
133
165
 
134
166
  const memory = collectMemory();
135
- const trans = listTranscripts(0).filter((t) => !judge.since || t.mtime > Date.parse(judge.since));
167
+ const trans = listTranscripts().filter((t) => !judge.since || t.mtime > Date.parse(judge.since));
136
168
  const sessions = [];
137
169
  let bytes = memory.length;
138
170
  for (const t of trans) { // newest first; drop oldest by simply stopping
@@ -233,6 +265,7 @@ export async function fetchOrphanResult(st) {
233
265
 
234
266
  // ─── direct CLI entry (detached worker) ──────────────────────────────────────
235
267
  if (process.argv[1] && basename(process.argv[1]) === "dream.mjs") {
268
+ if (process.argv[2] === "--badge") { console.log(JSON.stringify(badgeLine())); process.exit(0); }
236
269
  if (!existsSync(LOCK) && judgeMaterial(readState()).ok) {
237
270
  await runDream();
238
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro-claude-code",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Persistent memory for Claude Code — memories survive across sessions, projects, and machines",
5
5
  "author": {
6
6
  "name": "mingxy-cerebro",
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: apply-dream
3
+ description: Review and merge auto dream output. Use when the session-start report says Dream report ready, /dream status shows done, or the user asks to "check the dream result / merge memories". Prints a diff by default; runs --apply only after approval.
4
+ ---
5
+
6
+ # apply-dream — dream output review and merge
7
+
8
+ Script: `$CLAUDE_PLUGIN_ROOT/hooks/apply-dream.mjs` (shipped with the plugin — path never breaks)
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ # Step 1: read-only review (touches nothing)
14
+ node "$CLAUDE_PLUGIN_ROOT/hooks/apply-dream.mjs"
15
+
16
+ # Step 2: after the user reviews the diff and approves
17
+ node "$CLAUDE_PLUGIN_ROOT/hooks/apply-dream.mjs" --apply
18
+ ```
19
+
20
+ ## Merge contract (built into the script; here for cross-checking)
21
+
22
+ - join key = name; MEMORY.md index display names (often Chinese) resolve as a second-chance join
23
+ - kept entries: old archive wins verbatim; every LLM field except name is ignored
24
+ - unknown (unmatched names): surface to the user, never silently dropped
25
+ - dropped: listed for review first; deleted only with --apply
26
+ - updated/added: LLM content wins, written atomically
27
+
28
+ ## Reporting rule
29
+
30
+ Summarize the dry run in plain words: kept / updated / added / dropped counts, any unmatched names — wait for the user's go-ahead before --apply.
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: dream
3
+ description: Control the auto dream switch and status. Use when the user says /dream on, /dream off, /dream status, or "dream switch / dream status / enable / disable dreaming".
4
+ ---
5
+
6
+ # /dream — auto dream master switch
7
+
8
+ Args: `on` | `off` | `status` (default `status`).
9
+
10
+ ## Config file
11
+
12
+ `~/.cache/cerebro/dream/config.json`:
13
+
14
+ ```json
15
+ {"enabled": true, "badge_ttl_secs": 3600}
16
+ ```
17
+
18
+ - `enabled`: master switch. When false, every trigger path (session-end hook / session-start fallback / systemd timer) exits immediately — no dreaming.
19
+ - `badge_ttl_secs`: how long (seconds) the red fail badge stays on the statusline before falling back to the accumulating state.
20
+
21
+ ## on / off
22
+
23
+ Rewrite the whole file with the Write tool (Bash sandbox has a read-only home — no echo/jq redirects). Change only the `enabled` field, keep the rest verbatim. Then verify:
24
+
25
+ ```bash
26
+ node "$CLAUDE_PLUGIN_ROOT/hooks/dream.mjs" --badge
27
+ ```
28
+
29
+ Expected: off prints `{"text":"cerebro dream off","color":90}`; on returns to the normal state.
30
+
31
+ ## status
32
+
33
+ Report four items in plain words for the user (translate, no raw JSON):
34
+
35
+ 1. **Badge**: run `--badge` above and translate:
36
+ - `off` grey = disabled
37
+ - `run` orange = dreaming right now
38
+ - `fail` red = last dream failed (check `error` in state)
39
+ - `rdy` green = material ready, waiting for a trigger
40
+ - `[n/2]·Xh Ym` = accumulating: n new sessions since the last dream, Xh Ym left to the 6h window
41
+ 2. **State**: `jq '{phase, last_dream_at, error, consumed}' ~/.cache/cerebro/dream/state.json`
42
+ 3. **Timer**: `systemctl --user list-timers cerebro-dream.timer --no-pager | head -3` (sandbox blocks D-Bus with "Failed to connect to bus" — rerun via a host channel like `wsl.exe -- bash -lc`, or ask the user to run it with the `!` prefix)
43
+ 4. **Output**: `ls -t ~/.cache/cerebro/dream/output/ | head -3`; to consume a result suggest `/apply-dream` (diff only by default, writes after approval).