@mingxy/cerebro-claude-code 0.3.6 → 0.3.8
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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/apply-dream.mjs +11 -1
- package/hooks/common.mjs +30 -2
- package/hooks/flush-detached.mjs +4 -1
- package/hooks/session-end.mjs +12 -2
- package/hooks/session-start.mjs +24 -1
- package/package.json +1 -1
- package/tests/hooks.test.mjs +37 -0
package/hooks/apply-dream.mjs
CHANGED
|
@@ -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
|
@@ -268,6 +268,34 @@ export function readCompactResult() {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// ─── Pending clear flush (SessionEnd:clear → SessionStart:clear) ────────────
|
|
272
|
+
// /clear swaps session_id, so the old transcript can only be flushed by the
|
|
273
|
+
// NEW session's SessionStart(clear) hook (detached flush can't emit a toast).
|
|
274
|
+
const PENDING_CLEAR_FLUSH_FILE = join(HOME, ".config/cerebro/pending-clear-flush.json");
|
|
275
|
+
|
|
276
|
+
export function writePendingClearFlush(tp, sid) {
|
|
277
|
+
try {
|
|
278
|
+
mkdirSync(dirname(PENDING_CLEAR_FLUSH_FILE), { recursive: true });
|
|
279
|
+
writeFileSync(PENDING_CLEAR_FLUSH_FILE, JSON.stringify({ tp, sid, ts: Date.now() }));
|
|
280
|
+
} catch {}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function readPendingClearFlush() {
|
|
284
|
+
try {
|
|
285
|
+
if (!existsSync(PENDING_CLEAR_FLUSH_FILE)) return null;
|
|
286
|
+
const data = JSON.parse(readFileSync(PENDING_CLEAR_FLUSH_FILE, "utf-8"));
|
|
287
|
+
// Stale after 60s — user cleared but never resumed in this window
|
|
288
|
+
if (Date.now() - (data.ts || 0) > 60_000) return null;
|
|
289
|
+
return data;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function clearPendingClearFlush() {
|
|
296
|
+
try { unlinkSync(PENDING_CLEAR_FLUSH_FILE); } catch {}
|
|
297
|
+
}
|
|
298
|
+
|
|
271
299
|
// ─── Cursor (session ingest dedup) ───────────────────────────────────────────
|
|
272
300
|
const TRACKER_DIR = join(HOME, ".config/cerebro/trackers");
|
|
273
301
|
|
|
@@ -418,7 +446,7 @@ function contentText(content) {
|
|
|
418
446
|
return "";
|
|
419
447
|
}
|
|
420
448
|
|
|
421
|
-
export async function flushSessionIngest(transcriptPath, sessionId) {
|
|
449
|
+
export async function flushSessionIngest(transcriptPath, sessionId, timeoutSec = 25) {
|
|
422
450
|
if (!transcriptPath || !existsSync(transcriptPath) || !sessionId || !config.apiKey) return { ok: false, count: 0 };
|
|
423
451
|
|
|
424
452
|
const cursor = cursorGet(sessionId);
|
|
@@ -476,7 +504,7 @@ export async function flushSessionIngest(transcriptPath, sessionId) {
|
|
|
476
504
|
if (pn) body.project_name = pn;
|
|
477
505
|
if (pp) body.project_path = pp;
|
|
478
506
|
|
|
479
|
-
const result = await omPost("/v1/memories/session-ingest", body,
|
|
507
|
+
const result = await omPost("/v1/memories/session-ingest", body, timeoutSec);
|
|
480
508
|
if (result.status >= 200 && result.status < 300) {
|
|
481
509
|
cursorSet(sessionId, lastUid);
|
|
482
510
|
logDebug(`flush_session_ingest: ok http=${result.status} cursor=${lastUid}`);
|
package/hooks/flush-detached.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// cerebro detached flush — spawned by session-end.mjs to survive process exit
|
|
3
3
|
// Runs independently after Claude Code terminates. No stdin/stdout to Claude.
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { config, flushSessionIngest, logDebug, logError } from "./common.mjs";
|
|
5
|
+
import { config, flushSessionIngest, clearPendingClearFlush, logDebug, logError } from "./common.mjs";
|
|
6
6
|
import { judgeMaterial, readState, runDream } from "./dream.mjs";
|
|
7
7
|
|
|
8
8
|
const tp = process.env.CEREBRO_TP || "";
|
|
@@ -25,6 +25,9 @@ const result = await flushSessionIngest(tp, sid).catch((err) => {
|
|
|
25
25
|
|
|
26
26
|
if (result.ok) {
|
|
27
27
|
logDebug(`detached flush: ok count=${result.count} sid=${sid}`);
|
|
28
|
+
// Retrying a SessionStart(clear) handoff — consume it so the next SessionStart
|
|
29
|
+
// doesn't replay a 0-delta toast from the stale pending file.
|
|
30
|
+
if (process.env.CEREBRO_CLEAR_PENDING) clearPendingClearFlush();
|
|
28
31
|
} else {
|
|
29
32
|
logError(`detached flush: failed sid=${sid} http=${result.status || "?"} (cursor NOT advanced, will retry next session)`);
|
|
30
33
|
}
|
package/hooks/session-end.mjs
CHANGED
|
@@ -5,15 +5,25 @@
|
|
|
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 } from "./common.mjs";
|
|
8
|
+
import { config, PLUGIN_ROOT, refCountDec, parseStdinJSON, emit, logDebug, writePendingClearFlush } from "./common.mjs";
|
|
9
9
|
|
|
10
10
|
if (!config.apiKey) { emit({}); process.exit(0); }
|
|
11
11
|
|
|
12
12
|
const input = parseStdinJSON();
|
|
13
13
|
const tp = input.transcript_path || "";
|
|
14
14
|
const sid = input.session_id || input.sessionId || "";
|
|
15
|
+
const reason = input.reason || "";
|
|
15
16
|
|
|
16
|
-
logDebug(`session-end: sid=${sid} tp=${tp ? tp.slice(-40) : "EMPTY"}`);
|
|
17
|
+
logDebug(`session-end: sid=${sid} reason=${reason} tp=${tp ? tp.slice(-40) : "EMPTY"}`);
|
|
18
|
+
|
|
19
|
+
// /clear swaps session_id and fires SessionStart(clear) right after — hand the
|
|
20
|
+
// flush to it so the result lands in that hook's toast (detached flush is mute).
|
|
21
|
+
if (reason === "clear" && tp && sid) {
|
|
22
|
+
writePendingClearFlush(tp, sid);
|
|
23
|
+
refCountDec();
|
|
24
|
+
emit({});
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
17
27
|
|
|
18
28
|
// Spawn detached flush script — survives parent exit
|
|
19
29
|
if (tp && sid) {
|
package/hooks/session-start.mjs
CHANGED
|
@@ -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, injectionConfig,
|
|
7
|
+
config, PLUGIN_ROOT, PLUGIN_VERSION, detectProjectPath, parseStdinJSON, emit, buildMemoryInjection, postRecallEvent, refCountInc, readCompactResult, readPendingClearFlush, clearPendingClearFlush, flushSessionIngest, injectionConfig,
|
|
8
8
|
} from "./common.mjs";
|
|
9
9
|
import { judgeMaterial, readState, writeStateForReport, fetchOrphanResult } from "./dream.mjs";
|
|
10
10
|
|
|
@@ -131,6 +131,29 @@ if (startSource === "compact") {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// After /clear, the OLD session's tail flush runs here (SessionEnd:clear only
|
|
135
|
+
// wrote a handoff file) so its result shows in this toast like stop/compact.
|
|
136
|
+
if (startSource === "clear") {
|
|
137
|
+
const pending = readPendingClearFlush();
|
|
138
|
+
if (pending) {
|
|
139
|
+
const r = await flushSessionIngest(pending.tp, pending.sid, 8).catch(() => ({ ok: false, count: 0 }));
|
|
140
|
+
if (r.ok) {
|
|
141
|
+
clearPendingClearFlush();
|
|
142
|
+
statusMsg += ` · Clear ingest ✓ ${r.count} msgs`;
|
|
143
|
+
} else {
|
|
144
|
+
// Out of hook budget — detached retry with full 25s timeout, silent
|
|
145
|
+
try {
|
|
146
|
+
const child = spawn(process.execPath, [join(PLUGIN_ROOT, "hooks", "flush-detached.mjs")], {
|
|
147
|
+
detached: true, stdio: "ignore",
|
|
148
|
+
env: { ...process.env, CEREBRO_TP: pending.tp, CEREBRO_SID: pending.sid, CEREBRO_CLEAR_PENDING: "1" },
|
|
149
|
+
});
|
|
150
|
+
child.unref();
|
|
151
|
+
} catch {}
|
|
152
|
+
statusMsg += ` · Clear ingest ↻ background`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
// ─── POST recall event(让 web sessions 页面看到 CC session + 完整注入内容)─────
|
|
135
158
|
await postRecallEvent({
|
|
136
159
|
sessionId: sid,
|
package/package.json
CHANGED
package/tests/hooks.test.mjs
CHANGED
|
@@ -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
|
+
});
|