@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.
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ // OMEM dream trigger — detached worker, spawned by flush-detached.mjs / session-start.mjs
3
+ // Runs independently after Claude Code terminates. Owns the full dream lifecycle:
4
+ // lock → judge "enough material" → collect food → POST /v1/dreams → poll → write output
5
+ //
6
+ // State file protocol (~/.cache/cerebro/dream/state.json, tmp+rename atomic write):
7
+ // { phase: "run"|"fail"|"done", job_id, started_at, updated_at,
8
+ // last_dream_at, consumed, error?, stats? }
9
+ // Output: ~/.cache/cerebro/dream/output/<job_id>.json (full DreamResult)
10
+ // Lock: ~/.cache/cerebro/dream/trigger.lock (O_EXCL, TTL 15min > 600s server budget)
11
+ //
12
+ // Memory food = CC local memory dir (one md per memory, frontmatter verbatim).
13
+ // Sessions food = mechanical extraction from transcripts (user/assistant text only,
14
+ // tool_use/tool_result/system stripped), per-session cap, oldest dropped first when
15
+ // over the 512KB / 50-session server budget (P1-2).
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, readdirSync, statSync, renameSync } from "node:fs";
17
+ import { join, basename } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { config, logDebug, logError } from "./common.mjs";
20
+
21
+ const HOME = homedir();
22
+ const DREAM_DIR = process.env.OMEM_DREAM_DIR || join(HOME, ".cache", "cerebro", "dream");
23
+ const OUT_DIR = join(DREAM_DIR, "output");
24
+ const STATE = join(DREAM_DIR, "state.json");
25
+ const LOCK = join(DREAM_DIR, "trigger.lock");
26
+ // ponytail: hardcodes the home-project memory dir as the dream subject; DREAM_MEMORY_DIR
27
+ // escapes hatch for other projects if per-project dreams ever matter
28
+ const MEMORY_DIR = process.env.OMEM_DREAM_MEMORY_DIR || join(HOME, ".claude", "projects", "-home-dongx", "memory");
29
+ const PROJECTS_DIR = join(HOME, ".claude", "projects");
30
+
31
+ // Rhythm: dream when last dream ≥6h ago AND ≥2 new sessions since; 24h fallback.
32
+ const MIN_INTERVAL_MS = 6 * 3600 * 1000;
33
+ const FALLBACK_MS = 24 * 3600 * 1000;
34
+ const MIN_NEW_SESSIONS = 2;
35
+ const MAX_SESSIONS = 50; // server hard limit (validate_request)
36
+ const MAX_PAYLOAD = 512 * 1024; // server hard limit (P1-2)
37
+ const PER_SESSION_CAP = 16 * 1024; // per-transcript extraction budget
38
+ const LOCK_TTL_MS = 15 * 60 * 1000; // > 600s server job budget
39
+ const POLL_INTERVAL_MS = 2000;
40
+ const POLL_BUDGET_MS = 660 * 1000; // > 600s server timeout (ADR-3)
41
+
42
+ // ─── state helpers (tmp+rename atomic) ───────────────────────────────────────
43
+ export function readState() {
44
+ try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return null; }
45
+ }
46
+ function writeState(obj) {
47
+ mkdirSync(DREAM_DIR, { recursive: true });
48
+ const tmp = STATE + ".tmp";
49
+ writeFileSync(tmp, JSON.stringify(obj));
50
+ renameSync(tmp, STATE);
51
+ }
52
+
53
+ // ─── trigger lock (O_EXCL; cross-session on shared fs; TOCTOU-proof) ─────────
54
+ function acquireLock() {
55
+ mkdirSync(DREAM_DIR, { recursive: true });
56
+ try { closeSync(openSync(LOCK, "wx")); return true; } // we hold it
57
+ catch {
58
+ try {
59
+ const age = Date.now() - statSync(LOCK).mtimeMs;
60
+ if (age > LOCK_TTL_MS) { unlinkSync(LOCK); try { closeSync(openSync(LOCK, "wx")); return true; } catch {} }
61
+ } catch {}
62
+ return false;
63
+ }
64
+ }
65
+ function releaseLock() { try { unlinkSync(LOCK); } catch {} }
66
+
67
+ // ─── food: memory dir (verbatim md with frontmatter) ─────────────────────────
68
+ function collectMemory() {
69
+ const parts = [];
70
+ for (const f of readdirSync(MEMORY_DIR).filter((x) => x.endsWith(".md")).sort()) {
71
+ try { parts.push(readFileSync(join(MEMORY_DIR, f), "utf8")); } catch {}
72
+ }
73
+ return parts.join("\n\n");
74
+ }
75
+
76
+ // ─── food: sessions (mechanical extraction, no LLM in hooks) ─────────────────
77
+ function listTranscripts(sinceMs) {
78
+ const out = [];
79
+ for (const proj of readdirSync(PROJECTS_DIR)) {
80
+ const dir = join(PROJECTS_DIR, proj);
81
+ let st; try { st = statSync(dir); } catch { continue; }
82
+ if (!st.isDirectory()) continue;
83
+ for (const f of readdirSync(dir)) {
84
+ if (!f.endsWith(".jsonl")) continue;
85
+ const p = join(dir, f);
86
+ let s; try { s = statSync(p); } catch { continue; }
87
+ out.push({ path: p, mtime: s.mtimeMs });
88
+ }
89
+ }
90
+ return out.sort((a, b) => b.mtime - a.mtime); // newest first
91
+ }
92
+
93
+ function extractTranscript(path) {
94
+ const lines = readFileSync(path, "utf8").split("\n");
95
+ const texts = [];
96
+ let budget = PER_SESSION_CAP;
97
+ for (const line of lines) {
98
+ if (budget <= 0) break;
99
+ let j; try { j = JSON.parse(line); } catch { continue; }
100
+ if (j.type !== "user" && j.type !== "assistant") continue;
101
+ const c = j.message?.content;
102
+ let t = "";
103
+ if (typeof c === "string") t = c;
104
+ else if (Array.isArray(c)) for (const blk of c) { if (blk.type === "text") t += blk.text + "\n"; }
105
+ t = t.trim();
106
+ if (!t || t.startsWith("<")) continue; // skip hook injections / tool wrappers
107
+ t = t.slice(0, budget);
108
+ budget -= t.length;
109
+ texts.push(t);
110
+ }
111
+ return texts.join("\n---\n");
112
+ }
113
+
114
+ // ─── rhythm judge ────────────────────────────────────────────────────────────
115
+ export function judgeMaterial(state) {
116
+ 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);
119
+ const age = Date.now() - last;
120
+ const enoughTime = age >= MIN_INTERVAL_MS;
121
+ 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 };
123
+ }
124
+
125
+ // ─── detached main: trigger + poll + persist ─────────────────────────────────
126
+ export async function runDream() {
127
+ if (!config.apiKey) return;
128
+ if (!acquireLock()) { logDebug("dream: lock held, another window is dreaming"); return; }
129
+ try {
130
+ const prev = readState();
131
+ const judge = judgeMaterial(prev);
132
+ if (!judge.ok) { logDebug(`dream: not enough material (new=${judge.count})`); return; }
133
+
134
+ const memory = collectMemory();
135
+ const trans = listTranscripts(0).filter((t) => !judge.since || t.mtime > Date.parse(judge.since));
136
+ const sessions = [];
137
+ let bytes = memory.length;
138
+ for (const t of trans) { // newest first; drop oldest by simply stopping
139
+ if (sessions.length >= MAX_SESSIONS) break;
140
+ const s = extractTranscript(t.path);
141
+ if (bytes + s.length > MAX_PAYLOAD) break;
142
+ bytes += s.length;
143
+ sessions.push(s);
144
+ }
145
+ if (!sessions.length) { logDebug("dream: no session food extracted"); return; }
146
+
147
+ const started = new Date().toISOString();
148
+ writeState({ phase: "run", job_id: null, started_at: started, updated_at: started, last_dream_at: prev?.last_dream_at || null, consumed: false });
149
+
150
+ let resp;
151
+ try {
152
+ resp = await fetch(`${config.apiUrl}/v1/dreams`, {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json", "X-API-Key": config.apiKey },
155
+ body: JSON.stringify({ memory, sessions, since: judge.since }),
156
+ signal: AbortSignal.timeout(30_000),
157
+ });
158
+ } catch (e) {
159
+ writeState({ phase: "fail", error: `POST: ${e?.message || e}`, updated_at: new Date().toISOString(), last_dream_at: prev?.last_dream_at || null, consumed: true });
160
+ logError(`dream: POST failed ${e?.message || e}`);
161
+ return;
162
+ }
163
+ if (!resp.ok) {
164
+ const body = await resp.text().catch(() => "");
165
+ writeState({ phase: "fail", error: `POST http=${resp.status} ${body.slice(0, 200)}`, updated_at: new Date().toISOString(), last_dream_at: prev?.last_dream_at || null, consumed: true });
166
+ logError(`dream: POST http=${resp.status}`);
167
+ return;
168
+ }
169
+ const { id } = await resp.json();
170
+ const now = new Date().toISOString();
171
+ const st = readState() || {};
172
+ writeState({ ...st, phase: "run", job_id: id, updated_at: now }); // job_id immediately: orphans are queryable
173
+ logDebug(`dream: job ${id} accepted`);
174
+
175
+ // poll to terminal state (2s × 660s > 600s server budget)
176
+ const deadline = Date.now() + POLL_BUDGET_MS;
177
+ while (Date.now() < deadline) {
178
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
179
+ let jr;
180
+ try {
181
+ const g = await fetch(`${config.apiUrl}/v1/dreams/${id}`, { headers: { "X-API-Key": config.apiKey }, signal: AbortSignal.timeout(10_000) });
182
+ if (g.status === 404) continue; // server restart lost the job — keep polling til budget, then fail
183
+ jr = await g.json();
184
+ } catch { continue; }
185
+ if (jr.status === "completed" || jr.status === "failed") {
186
+ const ts = new Date().toISOString();
187
+ if (jr.status === "completed" && jr.result) {
188
+ mkdirSync(OUT_DIR, { recursive: true });
189
+ const outPath = join(OUT_DIR, `${id}.json`);
190
+ writeFileSync(outPath, JSON.stringify(jr.result));
191
+ writeState({ phase: "done", job_id: id, started_at: st.started_at, updated_at: ts, last_dream_at: ts, consumed: false, stats: jr.result.stats, output: outPath });
192
+ logDebug(`dream: completed stats=${JSON.stringify(jr.result.stats)}`);
193
+ } else {
194
+ writeState({ phase: "fail", job_id: id, error: jr.error || "job failed", updated_at: ts, last_dream_at: st.started_at, consumed: true });
195
+ logError(`dream: job failed ${jr.error || ""}`);
196
+ }
197
+ return;
198
+ }
199
+ }
200
+ writeState({ phase: "fail", job_id: id, error: "poll budget exhausted", updated_at: new Date().toISOString(), last_dream_at: st.started_at, consumed: true });
201
+ logError("dream: poll budget exhausted");
202
+ } finally {
203
+ releaseLock();
204
+ }
205
+ }
206
+
207
+ // ─── report-side helpers (used by session-start.mjs) ─────────────────────────
208
+ // Mark consumed so each new window doesn't re-inject the same report.
209
+ export function writeStateForReport(st) {
210
+ writeState({ ...st, consumed: true });
211
+ }
212
+
213
+ // Orphan recovery: single GET, no polling. completed → write output+done, return new state.
214
+ // running/pending → give up (next window retries). Network errors → return null (stale shown grey).
215
+ export async function fetchOrphanResult(st) {
216
+ if (!st?.job_id || !config.apiKey) return null;
217
+ try {
218
+ const g = await fetch(`${config.apiUrl}/v1/dreams/${st.job_id}`, {
219
+ headers: { "X-API-Key": config.apiKey },
220
+ signal: AbortSignal.timeout(6000), // single GET, 6s budget — same as recent-timeout precedent
221
+ });
222
+ const jr = await g.json();
223
+ if (jr.status !== "completed" || !jr.result) return null; // running/failed → next window
224
+ mkdirSync(OUT_DIR, { recursive: true });
225
+ const outPath = join(OUT_DIR, `${st.job_id}.json`);
226
+ writeFileSync(outPath, JSON.stringify(jr.result));
227
+ const ts = new Date().toISOString();
228
+ const ns = { ...st, phase: "done", updated_at: ts, last_dream_at: ts, consumed: false, stats: jr.result.stats, output: outPath };
229
+ writeState(ns);
230
+ return ns;
231
+ } catch { return null; }
232
+ }
233
+
234
+ // ─── direct CLI entry (detached worker) ──────────────────────────────────────
235
+ if (process.argv[1] && basename(process.argv[1]) === "dream.mjs") {
236
+ if (!existsSync(LOCK) && judgeMaterial(readState()).ok) {
237
+ await runDream();
238
+ }
239
+ process.exit(0);
240
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // cerebro detached flush — spawned by session-end.mjs to survive process exit
3
+ // Runs independently after Claude Code terminates. No stdin/stdout to Claude.
4
+ import { existsSync } from "node:fs";
5
+ import { config, flushSessionIngest, logDebug, logError } from "./common.mjs";
6
+ import { judgeMaterial, readState, runDream } from "./dream.mjs";
7
+
8
+ const tp = process.env.CEREBRO_TP || "";
9
+ const sid = process.env.CEREBRO_SID || "";
10
+
11
+ if (!tp || !sid || !config.apiKey) {
12
+ logError(`detached flush: skipped (tp=${!!tp} sid=${!!sid} key=${!!config.apiKey})`);
13
+ process.exit(0);
14
+ }
15
+
16
+ if (!existsSync(tp)) {
17
+ logError(`detached flush: transcript not found sid=${sid} tp=${tp}`);
18
+ process.exit(0);
19
+ }
20
+
21
+ const result = await flushSessionIngest(tp, sid).catch((err) => {
22
+ logError(`detached flush: exception sid=${sid} err=${err?.message || err}`);
23
+ return { ok: false, count: 0 };
24
+ });
25
+
26
+ if (result.ok) {
27
+ logDebug(`detached flush: ok count=${result.count} sid=${sid}`);
28
+ } else {
29
+ logError(`detached flush: failed sid=${sid} http=${result.status || "?"} (cursor NOT advanced, will retry next session)`);
30
+ }
31
+
32
+ // ─── dream trigger: session just ended = new material; judge then run ────────
33
+ // This detached worker already survives CC exit, so it also survives the
34
+ // ≤660s dream poll — no second spawn needed.
35
+ try {
36
+ const judge = judgeMaterial(readState());
37
+ if (judge.ok) {
38
+ logDebug(`dream: material ready (new=${judge.count}), dreaming after flush`);
39
+ await runDream();
40
+ }
41
+ } catch (e) {
42
+ logError(`dream trigger: ${e?.message || e}`);
43
+ }
44
+
45
+ process.exit(0);
@@ -0,0 +1,83 @@
1
+ {
2
+ "description": "cerebro — persistent memory hooks for Claude Code",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs",
10
+ "timeout": 15
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "UserPromptSubmit": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs",
21
+ "timeout": 10
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "PreToolUse": [
27
+ {
28
+ "matcher": "Skill|Bash",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/recall-approve.mjs",
33
+ "timeout": 10
34
+ }
35
+ ]
36
+ }
37
+ ],
38
+ "Stop": [
39
+ {
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs",
44
+ "timeout": 25
45
+ }
46
+ ]
47
+ }
48
+ ],
49
+ "SessionEnd": [
50
+ {
51
+ "hooks": [
52
+ {
53
+ "type": "command",
54
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-end.mjs",
55
+ "timeout": 30
56
+ }
57
+ ]
58
+ }
59
+ ],
60
+ "PreCompact": [
61
+ {
62
+ "hooks": [
63
+ {
64
+ "type": "command",
65
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs",
66
+ "timeout": 30
67
+ }
68
+ ]
69
+ }
70
+ ],
71
+ "PostCompact": [
72
+ {
73
+ "hooks": [
74
+ {
75
+ "type": "command",
76
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/post-compact.mjs",
77
+ "timeout": 30
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ }
83
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // cerebro PostCompact hook — flush + ingest compact_summary + toast
3
+ // PostCompact 在 CC 完成上下文压缩后触发,stdin 包含 compact_summary。
4
+ import {
5
+ config, flushSessionIngest, omPost, detectProjectName, detectProjectPath, parseStdinJSON, emit, logWarn, logError, writeCompactResult,
6
+ } from "./common.mjs";
7
+
8
+ if (!config.apiKey) { emit({}); process.exit(0); }
9
+
10
+ const input = parseStdinJSON();
11
+ const tp = input.transcript_path || "";
12
+ const sid = input.session_id || input.sessionId || "";
13
+ const summary = input.compact_summary || "";
14
+
15
+ if (!tp || !sid) {
16
+ logWarn("post-compact: missing transcript_path/session_id");
17
+ emit({});
18
+ process.exit(0);
19
+ }
20
+
21
+ // 1. Flush 压缩前剩余增量(PreCompact 已 flush 过则 delta=0,无副作用)
22
+ const result = await flushSessionIngest(tp, sid).catch(() => ({ ok: false, count: 0 }));
23
+ if (!result.ok) logError(`post-compact: flush failed for sid=${sid}`);
24
+
25
+ // 2. Ingest compact_summary 作为 assistant 消息(对标 opencode autocontinueHook)
26
+ let summaryOk = false;
27
+ if (summary.length > 100) {
28
+ const body = {
29
+ messages: [{ role: "assistant", content: `[compact_summary] ${summary.slice(0, 8000)}` }],
30
+ agent_id: process.env.OMEM_AGENT_ID || "claude-code",
31
+ session_id: sid,
32
+ source: "compact_summary",
33
+ };
34
+ const pn = detectProjectName();
35
+ const pp = detectProjectPath();
36
+ if (pn) body.project_name = pn;
37
+ if (pp) body.project_path = pp;
38
+ const res = await omPost("/v1/memories/session-ingest", body, 25);
39
+ summaryOk = res.status >= 200 && res.status < 300;
40
+ }
41
+
42
+ const totalCount = result.count + (summaryOk ? 1 : 0);
43
+
44
+ // Write result for SessionStart:compact to pick up and merge into its toast
45
+ writeCompactResult({ ok: result.ok || summaryOk, count: totalCount });
46
+
47
+ emit({
48
+ systemMessage: `🧠 Cerebro · Post-compact · ${totalCount} items ingested`,
49
+ });
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // cerebro PreCompact hook — flush delta before compaction
3
+ // PreCompact 不支持 hookSpecificOutput.additionalContext(CC schema 限制)。
4
+ // 仅做 flushSessionIngest,压缩指导由 CLAUDE.md 中的 cerebro-recall 指令覆盖。
5
+ import { config, flushSessionIngest, parseStdinJSON, emit, logWarn, logError } from "./common.mjs";
6
+
7
+ if (!config.apiKey) { emit({}); process.exit(0); }
8
+
9
+ const input = parseStdinJSON();
10
+ const tp = input.transcript_path || "";
11
+ const sid = input.session_id || input.sessionId || "";
12
+
13
+ if (!tp || !sid) {
14
+ logWarn("pre-compact: missing transcript_path/session_id");
15
+ emit({});
16
+ process.exit(0);
17
+ }
18
+
19
+ const result = await flushSessionIngest(tp, sid).catch(() => ({ ok: false, count: 0 }));
20
+ if (!result.ok) logError(`pre-compact: flush_session_ingest failed for sid=${sid} (will retry at SessionEnd)`);
21
+
22
+ emit({
23
+ systemMessage: result.ok
24
+ ? `🧠 Cerebro · Pre-compact flush · ${result.count} messages ingested`
25
+ : `🧠 Cerebro · Pre-compact flush failed (will retry at SessionEnd)`,
26
+ });
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // cerebro PreToolUse hook — auto-approve memory-search calls
3
+ // matcher: Skill|Bash (hooks.json). Detects memory-search → permissionDecision=allow.
4
+ import { parseStdinJSON, emit } from "./common.mjs";
5
+
6
+ const d = parseStdinJSON();
7
+ const toolName = (d.tool_name || "").toLowerCase();
8
+ const toolInput = d.tool_input || {};
9
+ const cmd = toolInput.command || "";
10
+ const name = toolInput.name || "";
11
+
12
+ let isSearch = false;
13
+ if (toolName.includes("memory-search")) {
14
+ isSearch = true;
15
+ } else if (toolName === "skill" && name.toLowerCase().includes("memory-search")) {
16
+ isSearch = true;
17
+ } else if (toolName === "bash" && cmd.includes("memory-search")) {
18
+ // 无危险 shell 操作符(防止拼接注入)
19
+ isSearch = !/[;&|`]/.test(cmd) && !cmd.includes("$(");
20
+ }
21
+
22
+ if (isSearch) {
23
+ emit({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } });
24
+ } else {
25
+ emit({});
26
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // cerebro SessionEnd hook — spawn detached flush to survive process exit
3
+ // Claude Code does NOT wait for SessionEnd hooks to complete ("cannot block
4
+ // session termination"). Direct await fetch gets killed → http=0.
5
+ // Fix: spawn detached child process, parent exits immediately.
6
+ import { spawn } from "node:child_process";
7
+ import { join } from "node:path";
8
+ import { config, PLUGIN_ROOT, refCountDec, parseStdinJSON, emit, logDebug } from "./common.mjs";
9
+
10
+ if (!config.apiKey) { emit({}); process.exit(0); }
11
+
12
+ const input = parseStdinJSON();
13
+ const tp = input.transcript_path || "";
14
+ const sid = input.session_id || input.sessionId || "";
15
+
16
+ logDebug(`session-end: sid=${sid} tp=${tp ? tp.slice(-40) : "EMPTY"}`);
17
+
18
+ // Spawn detached flush script — survives parent exit
19
+ if (tp && sid) {
20
+ const flushScript = join(PLUGIN_ROOT, "hooks", "flush-detached.mjs");
21
+ try {
22
+ const child = spawn(process.execPath, [flushScript], {
23
+ detached: true,
24
+ stdio: "ignore",
25
+ env: { ...process.env, CEREBRO_TP: tp, CEREBRO_SID: sid },
26
+ });
27
+ child.unref();
28
+ } catch {}
29
+ }
30
+
31
+ refCountDec();
32
+ emit({
33
+ systemMessage: `🧠 Cerebro · Session flush in background`,
34
+ });
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // cerebro SessionStart hook — profile + recent injection + recall event + web server
3
+ import { spawn } from "node:child_process";
4
+ import { join } from "node:path";
5
+ import { readFileSync } from "node:fs";
6
+ import {
7
+ config, PLUGIN_ROOT, PLUGIN_VERSION, detectProjectPath, parseStdinJSON, emit, buildMemoryInjection, postRecallEvent, refCountInc, readCompactResult, injectionConfig,
8
+ } from "./common.mjs";
9
+ import { judgeMaterial, readState, writeStateForReport, fetchOrphanResult } from "./dream.mjs";
10
+
11
+ // ─── dream report: read local output, mark consumed; never write memory here ──
12
+ // URGENT is ratio-based: dropped >10% of total OR added ≥2 → flag for the
13
+ // session's Claude to surface to the user. The full new archive stays on disk —
14
+ // writing memory files is the session Claude's job, after the user reviews.
15
+ async function dreamReport() {
16
+ try {
17
+ let st = readState();
18
+ if (!st) return "";
19
+ // orphan: run state older than 15min → one single GET, no polling
20
+ if (st.phase === "run" && Date.now() - Date.parse(st.updated_at) > 15 * 60 * 1000 && st.job_id) {
21
+ st = (await fetchOrphanResult(st)) || st;
22
+ }
23
+ if (st.phase === "fail") {
24
+ return `[dream-report] last dream failed: ${st.error || "?"} (${st.updated_at})\n`;
25
+ }
26
+ if (st.phase !== "done" || st.consumed || !st.output) return "";
27
+ const result = JSON.parse(readFileSync(st.output, "utf8"));
28
+ const s = result.stats || {};
29
+ const total = s.total || 1;
30
+ const urgent = (s.dropped || 0) > total * 0.1 || (s.added || 0) >= 2;
31
+ const lines = [
32
+ `[dream-report] job ${st.job_id?.slice(0, 8)} · ${st.updated_at}`,
33
+ `merged ${s.merged || 0} / updated ${s.updated || 0} / added ${s.added || 0} / dropped ${s.dropped || 0} / kept ${total - (s.merged || 0) - (s.updated || 0) - (s.added || 0) - (s.dropped || 0)}`,
34
+ ];
35
+ for (const e of result.entries || []) {
36
+ lines.push(`· ${e.action || "?"} ${e.name || "?"} — ${e.description || ""}`);
37
+ }
38
+ if (urgent) lines.push(`⚠ URGENT: significant dream changes above — surface this report to the user NOW.`);
39
+ lines.push(`New full archive on disk: ${st.output}`);
40
+ lines.push(`Do NOT auto-overwrite memory files: show the user a diff, let them approve, then write.`);
41
+ writeStateForReport(st); // consumed=true
42
+ return lines.join("\n") + "\n";
43
+ } catch { return ""; }
44
+ }
45
+
46
+ // ─── dream follow-up: material ready but no window ended recently? start one ──
47
+ // Async spawn, never blocks startup. Complements the SessionEnd trigger.
48
+ function dreamFollowUp() {
49
+ try {
50
+ const st = readState();
51
+ if (st?.phase === "run" || (st?.phase === "done" && !st.consumed)) return; // busy or unreported
52
+ if (!judgeMaterial(st).ok) return;
53
+ const child = spawn(process.execPath, [join(PLUGIN_ROOT, "hooks", "dream.mjs")], {
54
+ detached: true, stdio: "ignore", env: { ...process.env },
55
+ });
56
+ child.unref();
57
+ } catch {}
58
+ }
59
+
60
+ const input = parseStdinJSON();
61
+ const sid = input.session_id || "";
62
+ const startSource = input.source || ""; // "startup" | "resume" | "clear" | "compact"
63
+
64
+ // ─── web server 拉起(probe + detached spawn,跨平台)─────────────────────────
65
+ const webPort = process.env.OMEM_LOCAL_PORT || "5212";
66
+ 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();
82
+
83
+ // ─── API key 检查 ────────────────────────────────────────────────────────────
84
+ if (!config.apiKey) {
85
+ const msg = `[cerebro] OMEM_API_KEY not set — memory is disabled.
86
+
87
+ To enable persistent memory, set your API key:
88
+ export OMEM_API_KEY="your-key"
89
+
90
+ Get a free key:
91
+ curl -X POST ${config.apiUrl}/v1/tenants -H "Content-Type: application/json" -d "{}"
92
+
93
+ Then restart Claude Code.`;
94
+ emit({ systemMessage: "🧠 Cerebro: API key not set — memory disabled", hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: msg } });
95
+ process.exit(0);
96
+ }
97
+
98
+ // ─── 时间标记 ────────────────────────────────────────────────────────────────
99
+ function cerebroTime() {
100
+ const d = new Date(Date.now() + 8 * 3600 * 1000);
101
+ const pad = (n) => String(n).padStart(2, "0");
102
+ const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
103
+ return `[CEREBRO-TIME] 当前: ${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} ${days[d.getUTCDay()]}`;
104
+ }
105
+
106
+ // ─── buildMemoryInjection(对标 opencode buildMemoryInjection)──────────────────
107
+ const pp = detectProjectPath();
108
+ const ss = injectionConfig.sessionStart || {};
109
+ const injection = await buildMemoryInjection("", pp, {
110
+ profileEnabled: ss.profileEnabled !== false,
111
+ recentEnabled: ss.recentActivityEnabled !== false,
112
+ });
113
+
114
+ // CEREBRO-TIME 注入 Claude 上下文(Claude 需要时间感知)
115
+ let out = injection.text;
116
+ const timeLine = cerebroTime();
117
+ out = out.replace("[CEREBRO-MEMORY]", `[CEREBRO-MEMORY]\n${timeLine}`);
118
+
119
+ // CEREBRO-STATUS 通过 systemMessage 显示给用户(Q2: toast 替代方案)
120
+ const memCount = injection.projectMemoryCount + injection.searchCount;
121
+ 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 ? "✓" : "✗"}`;
124
+
125
+ // After compact, PostCompact toast gets overridden by SessionStart:compact toast.
126
+ // Merge PostCompact ingest result into this toast so user sees it.
127
+ if (startSource === "compact") {
128
+ const cr = readCompactResult();
129
+ if (cr) {
130
+ statusMsg += ` · Post-compact ingest ${cr.ok ? "✓" : "✗"} ${cr.count} items`;
131
+ }
132
+ }
133
+
134
+ // ─── POST recall event(让 web sessions 页面看到 CC session + 完整注入内容)─────
135
+ await postRecallEvent({
136
+ sessionId: sid,
137
+ recallType: "session_start",
138
+ queryText: `Session Start · ${injection.projectMemoryCount} memories · ${injection.profileCount > 0 ? "profile" : "no profile"}`,
139
+ profileInjected: injection.profileCount > 0,
140
+ keptCount: injection.projectMemoryCount,
141
+ injectedContent: out,
142
+ failureReason: injection.recentFailed ? "recent fetch failed/timeout" : "",
143
+ });
144
+
145
+ // ─── dream:报告注入(本地读,零网络)+ 料够补刀(异步 spawn)───────────────────
146
+ const drep = await dreamReport();
147
+ if (drep) {
148
+ out = drep + out;
149
+ statusMsg += " · Dream report ready";
150
+ }
151
+ dreamFollowUp();
152
+
153
+ emit({
154
+ systemMessage: statusMsg,
155
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: out },
156
+ });