@mingxy/cerebro 1.19.2 → 1.20.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro",
3
- "version": "1.19.2",
3
+ "version": "1.20.0",
4
4
  "description": "Cerebro persistent memory plugin for OpenCode — auto-recall, auto-capture, 9 memory tools with clustering, project-scoped memory isolation",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -39,6 +39,7 @@ export interface CerebroPluginConfig {
39
39
  };
40
40
  agentMemoryPolicy?: Record<string, "none" | "readonly" | "readwrite">;
41
41
  defaultPolicy?: "none" | "readonly" | "readwrite";
42
+ autoUpdate?: boolean; // default false
42
43
  }
43
44
 
44
45
  // ── Defaults ─────────────────────────────────────────────────────────
@@ -75,6 +76,7 @@ const DEFAULTS: CerebroPluginConfig = {
75
76
  web: {
76
77
  enabled: true,
77
78
  },
79
+ autoUpdate: false,
78
80
  };
79
81
 
80
82
  // ── Flat-to-nested migration ─────────────────────────────────────────
@@ -146,6 +148,7 @@ function deepMerge(base: CerebroPluginConfig, overrides: Partial<CerebroPluginCo
146
148
  result.web = { ...base.web!, ...overrides.web };
147
149
  if (overrides.agentMemoryPolicy) result.agentMemoryPolicy = overrides.agentMemoryPolicy;
148
150
  if (overrides.defaultPolicy) result.defaultPolicy = overrides.defaultPolicy;
151
+ if (overrides.autoUpdate !== undefined) result.autoUpdate = overrides.autoUpdate;
149
152
  return result;
150
153
  }
151
154
 
package/src/hooks.ts CHANGED
@@ -226,6 +226,8 @@ interface InjectionResult {
226
226
  profileCount: number;
227
227
  memoryCount: number;
228
228
  projectMemoryCount: number;
229
+ maxScore: number;
230
+ confidence: number;
229
231
  }
230
232
 
231
233
  export async function buildMemoryInjection(
@@ -297,11 +299,16 @@ export async function buildMemoryInjection(
297
299
  text = text.slice(0, cutoff > 0 ? cutoff : maxChars) + "\n…\n[/CEREBRO-MEMORY]";
298
300
  }
299
301
 
302
+ const maxScore = searchResults.reduce((max, r) => Math.max(max, r.score), 0);
303
+ const confidence = Math.min(maxScore, 1.0);
304
+
300
305
  return {
301
306
  text,
302
307
  profileCount: profile?.preference_count ?? 0,
303
308
  memoryCount: dedupedResults?.length ?? 0,
304
309
  projectMemoryCount: projectMemories.length,
310
+ maxScore,
311
+ confidence,
305
312
  };
306
313
  }
307
314
 
@@ -371,8 +378,8 @@ export function chatMessageRecallHook(
371
378
  session_id: input.sessionID,
372
379
  recall_type: "auto",
373
380
  query_text: query,
374
- max_score: 0,
375
- llm_confidence: 0,
381
+ max_score: injection.maxScore,
382
+ llm_confidence: injection.confidence,
376
383
  profile_injected: injection.profileCount > 0,
377
384
  kept_count: injection.projectMemoryCount + injection.memoryCount,
378
385
  discarded_count: 0,
package/src/index.ts CHANGED
@@ -3,7 +3,6 @@ import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
- import type { Server } from "node:http";
7
6
  import { CerebroClient } from "./client.js";
8
7
  import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook, createToast, sessionMessages, firstMessages } from "./hooks.js";
9
8
  import { detectSaveKeyword, detectRecallKeyword, KEYWORD_NUDGE, RECALL_NUDGE } from "./keywords.js";
@@ -11,7 +10,8 @@ import { getUserTag, getProjectTag } from "./tags.js";
11
10
  import { buildTools } from "./tools.js";
12
11
  import { logInfo, logDebug, logError } from "./logger.js";
13
12
  import { loadPluginConfig, resolveAgentPolicy } from "./config.js";
14
- import { startWebServer, stopWebServer } from "./web-server.js";
13
+ import { checkAndUpdate } from "./updater.js";
14
+ import { startWebServer, stopWebServer, type WebServerHandle } from "./web-server.js";
15
15
 
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = dirname(__filename);
@@ -103,7 +103,7 @@ const OmemPlugin: Plugin = async (input) => {
103
103
 
104
104
  const chatMessageRecall = chatMessageRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
105
105
 
106
- let webServer: Server | null = null;
106
+ let webServer: WebServerHandle | null = null;
107
107
  const webEnabled = config.web?.enabled !== false;
108
108
  let webPort: number | undefined;
109
109
  if (webEnabled) {
@@ -125,9 +125,12 @@ const OmemPlugin: Plugin = async (input) => {
125
125
  if (webPort) {
126
126
  toast(tui, `🧠 Cerebro Connected · v${pluginVersion}`, `🌐 Open in browser http://localhost:${webPort}`, "success");
127
127
  } else {
128
- toast(tui, "🧠 Connected", `v${pluginVersion}`, "success");
128
+ toast(tui, `🧠 Cerebro Connected · v${pluginVersion}`, "No web server", "success");
129
129
  }
130
130
 
131
+ // Auto-update check (fire-and-forget, non-blocking)
132
+ checkAndUpdate(tui, pluginVersion).catch(() => {});
133
+
131
134
  const shutdown = async () => {
132
135
  try {
133
136
  if (webServer) {
package/src/logger.ts CHANGED
@@ -1,66 +1,160 @@
1
- import { appendFileSync, mkdirSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { loadPluginConfig } from "./config.js";
4
-
5
- const LEVEL_MAP: Record<string, number> = {
6
- DEBUG: 0,
7
- INFO: 1,
8
- WARN: 2,
9
- ERROR: 3,
10
- };
11
-
12
- const cfg = loadPluginConfig();
13
- const MIN_LEVEL = LEVEL_MAP[cfg.logging.logLevel] ?? LEVEL_MAP.INFO;
14
- const LOG_DIR = cfg.logging.logDir;
15
- const LOG_FILE = join(LOG_DIR, "plugin.log");
16
- const LOG_ENABLED = cfg.logging.logEnabled;
17
-
18
- let lastLogTime = Date.now();
19
-
20
- function ensureLogDir(): void {
21
- if (!existsSync(LOG_DIR)) {
22
- try {
23
- mkdirSync(LOG_DIR, { recursive: true });
24
- } catch {}
25
- }
26
- }
27
-
28
- function writeLog(level: string, message: string, fields?: Record<string, unknown>): void {
29
- if (!LOG_ENABLED) return;
30
- const lvl = LEVEL_MAP[level] ?? 0;
31
- if (lvl < MIN_LEVEL) return;
32
- ensureLogDir();
33
- const now = new Date();
34
- const nowMs = now.getTime();
35
- const delta = ((nowMs - lastLogTime) / 1000).toFixed(2);
36
- lastLogTime = nowMs;
37
- const pad = (n: number) => String(n).padStart(2, "0");
38
- const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
39
- const parts = [`${level.padEnd(5)} ${ts} +${delta}s service=cerebro`];
40
- if (fields) {
41
- for (const [k, v] of Object.entries(fields)) {
42
- const val = typeof v === "string" ? v : JSON.stringify(v);
43
- parts.push(`${k}=${val}`);
44
- }
45
- }
46
- parts.push(message);
47
- try {
48
- appendFileSync(LOG_FILE, parts.join(" ") + "\n");
49
- } catch {}
50
- }
51
-
52
- export function logInfo(message: string, fields?: Record<string, unknown>): void {
53
- writeLog("INFO", message, fields);
54
- }
55
-
56
- export function logWarn(message: string, fields?: Record<string, unknown>): void {
57
- writeLog("WARN", message, fields);
58
- }
59
-
60
- export function logError(message: string, fields?: Record<string, unknown>): void {
61
- writeLog("ERROR", message, fields);
62
- }
63
-
64
- export function logDebug(message: string, fields?: Record<string, unknown>): void {
65
- writeLog("DEBUG", message, fields);
66
- }
1
+ import {
2
+ appendFileSync,
3
+ mkdirSync,
4
+ existsSync,
5
+ statSync,
6
+ renameSync,
7
+ readdirSync,
8
+ unlinkSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { loadPluginConfig } from "./config.js";
12
+
13
+ // ── Level map ─────────────────────────────────────────────────────────
14
+
15
+ const LEVEL_MAP: Record<string, number> = {
16
+ DEBUG: 0,
17
+ INFO: 1,
18
+ WARN: 2,
19
+ ERROR: 3,
20
+ };
21
+
22
+ // ── Config access with 30-second TTL cache ────────────────────────────
23
+
24
+ let cachedConfig: ReturnType<typeof loadPluginConfig> | null = null;
25
+ let configCacheTime = 0;
26
+ const CONFIG_TTL_MS = 30_000;
27
+
28
+ function getConfig() {
29
+ const now = Date.now();
30
+ if (cachedConfig && (now - configCacheTime) < CONFIG_TTL_MS) return cachedConfig;
31
+ cachedConfig = loadPluginConfig();
32
+ configCacheTime = now;
33
+ return cachedConfig;
34
+ }
35
+
36
+ function getLogFilePath(): string {
37
+ return join(getConfig().logging.logDir, "plugin.log");
38
+ }
39
+
40
+ function getMinLevel(): number {
41
+ return LEVEL_MAP[getConfig().logging.logLevel] ?? LEVEL_MAP.INFO;
42
+ }
43
+
44
+ function isLogEnabled(): boolean {
45
+ return getConfig().logging.logEnabled;
46
+ }
47
+
48
+ // ── Opencode client for dual-track logging ────────────────────────────
49
+
50
+ let opencodeClient: any = null;
51
+
52
+ export function setOpencodeClient(client: any): void {
53
+ opencodeClient = client;
54
+ }
55
+
56
+ // ── Log file rotation (5 MB threshold) ────────────────────────────────
57
+ // NOTE: multi-window concurrent rotate is a known limitation — the last
58
+ // writer to rename wins; earlier writers will create a fresh file.
59
+
60
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
61
+
62
+ function rotateIfNeeded(logFile: string): void {
63
+ try {
64
+ const s = statSync(logFile);
65
+ if (s.size > MAX_LOG_SIZE) {
66
+ renameSync(logFile, logFile.replace(".log", ".old.log"));
67
+ }
68
+ } catch { /* file doesn't exist yet, first write */ }
69
+ }
70
+
71
+ // ── Startup cleanup (delete logs older than 7 days) ───────────────────
72
+
73
+ const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
74
+
75
+ function cleanupOldLogs(): void {
76
+ const logDir = getConfig().logging.logDir;
77
+ try {
78
+ const files = readdirSync(logDir);
79
+ const cutoff = Date.now() - LOG_MAX_AGE_MS;
80
+ for (const f of files) {
81
+ if (!f.endsWith(".log") && !f.endsWith(".old.log")) continue;
82
+ const fp = join(logDir, f);
83
+ try {
84
+ const s = statSync(fp);
85
+ if (s.mtimeMs < cutoff) unlinkSync(fp);
86
+ } catch {}
87
+ }
88
+ } catch {}
89
+ }
90
+
91
+ // Run cleanup once at module load
92
+ cleanupOldLogs();
93
+
94
+ // ── Core logging ──────────────────────────────────────────────────────
95
+
96
+ let lastLogTime = Date.now();
97
+
98
+ function ensureLogDir(logDir: string): void {
99
+ if (!existsSync(logDir)) {
100
+ try {
101
+ mkdirSync(logDir, { recursive: true });
102
+ } catch {}
103
+ }
104
+ }
105
+
106
+ function writeLog(level: string, message: string, fields?: Record<string, unknown>): void {
107
+ if (!isLogEnabled()) return;
108
+ const lvl = LEVEL_MAP[level] ?? 0;
109
+ if (lvl < getMinLevel()) return;
110
+
111
+ const cfg = getConfig();
112
+ const logFile = getLogFilePath();
113
+ ensureLogDir(cfg.logging.logDir);
114
+
115
+ const now = new Date();
116
+ const nowMs = now.getTime();
117
+ const delta = ((nowMs - lastLogTime) / 1000).toFixed(2);
118
+ lastLogTime = nowMs;
119
+ const pad = (n: number) => String(n).padStart(2, "0");
120
+ const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
121
+ const parts = [`${level.padEnd(5)} ${ts} +${delta}s service=cerebro`];
122
+ if (fields) {
123
+ for (const [k, v] of Object.entries(fields)) {
124
+ const val = typeof v === "string" ? v : JSON.stringify(v);
125
+ parts.push(`${k}=${val}`);
126
+ }
127
+ }
128
+ parts.push(message);
129
+
130
+ // Track 1: file
131
+ try {
132
+ rotateIfNeeded(logFile);
133
+ appendFileSync(logFile, parts.join(" ") + "\n");
134
+ } catch {}
135
+
136
+ // Track 2: opencode client
137
+ try {
138
+ opencodeClient?.app?.log({
139
+ body: { service: "cerebro", level: level.toLowerCase(), message, extra: fields },
140
+ });
141
+ } catch { /* opencode client not available, skip */ }
142
+ }
143
+
144
+ // ── Public API ────────────────────────────────────────────────────────
145
+
146
+ export function logInfo(message: string, fields?: Record<string, unknown>): void {
147
+ writeLog("INFO", message, fields);
148
+ }
149
+
150
+ export function logWarn(message: string, fields?: Record<string, unknown>): void {
151
+ writeLog("WARN", message, fields);
152
+ }
153
+
154
+ export function logError(message: string, fields?: Record<string, unknown>): void {
155
+ writeLog("ERROR", message, fields);
156
+ }
157
+
158
+ export function logDebug(message: string, fields?: Record<string, unknown>): void {
159
+ writeLog("DEBUG", message, fields);
160
+ }
package/src/updater.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createRequire } from "node:module";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { openSync, closeSync, unlinkSync, statSync, writeSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
7
+ import { loadPluginConfig } from "./config.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const require = createRequire(import.meta.url);
11
+
12
+ // ── Version fetching ────────────────────────────────────────────────
13
+
14
+ async function getLatestVersion(): Promise<string | null> {
15
+ try {
16
+ const { stdout } = await execFileAsync("npm", ["view", "@mingxy/cerebro", "version"], { timeout: 10000 });
17
+ return stdout.trim();
18
+ } catch { return null; }
19
+ }
20
+
21
+ // ── Semantic version comparison ─────────────────────────────────────
22
+
23
+ function compareVersions(a: string, b: string): number {
24
+ const pa = a.replace(/^v/, "").split(".").map(Number);
25
+ const pb = b.replace(/^v/, "").split(".").map(Number);
26
+ for (let i = 0; i < 3; i++) {
27
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
28
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ // ── Install dir detection ───────────────────────────────────────────
34
+
35
+ function getInstallDir(): string {
36
+ try {
37
+ const pkgPath = require.resolve("@mingxy/cerebro/package.json");
38
+ return dirname(pkgPath);
39
+ } catch {
40
+ return join(homedir(), ".cache", "opencode", "packages", "@mingxy", "cerebro");
41
+ }
42
+ }
43
+
44
+ // ── Install update via npm pack + tar ───────────────────────────────
45
+
46
+ async function installUpdate(targetDir: string): Promise<boolean> {
47
+ const tmpDir = mkdtempSync(join(tmpdir(), "cerebro-update-"));
48
+ try {
49
+ // 1. Download tgz
50
+ await execFileAsync("npm", ["pack", "@mingxy/cerebro@latest", "--pack-destination", tmpDir], { timeout: 60000 });
51
+
52
+ // 2. Find the tgz file
53
+ const files = readdirSync(tmpDir);
54
+ const tgz = files.find(f => f.endsWith(".tgz"));
55
+ if (!tgz) return false;
56
+
57
+ // 3. Extract to target dir (strip the "package/" prefix from tgz)
58
+ await execFileAsync("tar", ["-xzf", join(tmpDir, tgz), "-C", targetDir, "--strip-components=1", "--no-same-owner", "--no-same-permissions"], { timeout: 30000 });
59
+
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ } finally {
64
+ try { rmSync(tmpDir, { recursive: true }); } catch {}
65
+ }
66
+ }
67
+
68
+ // ── File lock with stale lock cleanup ───────────────────────────────
69
+
70
+ const LOCK_FILE = join(tmpdir(), "cerebro-update.lock");
71
+ const STALE_LOCK_MS = 5 * 60 * 1000; // 5 minutes
72
+ let lockFd: number | null = null;
73
+
74
+ function acquireLock(): boolean {
75
+ try {
76
+ // Clean up stale lock first
77
+ try {
78
+ const s = statSync(LOCK_FILE);
79
+ if (Date.now() - s.mtimeMs > STALE_LOCK_MS) {
80
+ unlinkSync(LOCK_FILE);
81
+ }
82
+ } catch { /* lock file doesn't exist, normal */ }
83
+
84
+ lockFd = openSync(LOCK_FILE, "wx");
85
+ writeSync(lockFd, String(process.pid));
86
+ return true;
87
+ } catch { return false; }
88
+ }
89
+
90
+ function releaseLock(): void {
91
+ if (lockFd !== null) {
92
+ try { closeSync(lockFd); } catch {}
93
+ lockFd = null;
94
+ try { unlinkSync(LOCK_FILE); } catch {}
95
+ }
96
+ }
97
+
98
+ // ── Main entry ──────────────────────────────────────────────────────
99
+
100
+ export async function checkAndUpdate(tui: any, currentVersion: string): Promise<void> {
101
+ const config = loadPluginConfig();
102
+ if (!config.autoUpdate) return;
103
+
104
+ const latest = await getLatestVersion();
105
+ if (!latest) return;
106
+
107
+ if (compareVersions(latest, currentVersion) <= 0) return; // already up to date
108
+
109
+ if (!acquireLock()) return; // another process is updating
110
+
111
+ try {
112
+ const targetDir = getInstallDir();
113
+ const success = await installUpdate(targetDir);
114
+
115
+ if (success) {
116
+ try {
117
+ tui?.showToast?.({
118
+ body: { message: `Cerebro updated to v${latest} — restart opencode to apply`, variant: "info" }
119
+ });
120
+ } catch {}
121
+ }
122
+ } finally {
123
+ releaseLock();
124
+ }
125
+ }