@mingxy/cerebro 1.20.0 → 1.20.2
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 +1 -1
- package/src/config.ts +1 -1
- package/src/index.ts +3 -1
- package/src/logger.ts +8 -3
- package/src/updater.ts +37 -7
- package/src/web-server-child.ts +31 -9
- package/src/web-server.ts +15 -17
- package/web/assets/{index-A2-GzPke.js → index-D1bToVHW.js} +2 -2
- package/web/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/cerebro",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.2",
|
|
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
|
@@ -177,7 +177,7 @@ function configLog(message: string, fields?: Record<string, unknown>, level: str
|
|
|
177
177
|
if (lvl < CONFIGURED_MIN_LEVEL) return;
|
|
178
178
|
try {
|
|
179
179
|
const logDir = join(homedir(), ".config", "cerebro", "logs");
|
|
180
|
-
const logPath = join(logDir, "
|
|
180
|
+
const logPath = join(logDir, "cerebro.log");
|
|
181
181
|
const ts = new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
182
182
|
const parts = [`${level.padEnd(5)} ${ts} service=cerebro ${message}`];
|
|
183
183
|
if (fields) {
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHoo
|
|
|
8
8
|
import { detectSaveKeyword, detectRecallKeyword, KEYWORD_NUDGE, RECALL_NUDGE } from "./keywords.js";
|
|
9
9
|
import { getUserTag, getProjectTag } from "./tags.js";
|
|
10
10
|
import { buildTools } from "./tools.js";
|
|
11
|
-
import { logInfo, logDebug, logError } from "./logger.js";
|
|
11
|
+
import { logInfo, logDebug, logError, setOpencodeClient } from "./logger.js";
|
|
12
12
|
import { loadPluginConfig, resolveAgentPolicy } from "./config.js";
|
|
13
13
|
import { checkAndUpdate } from "./updater.js";
|
|
14
14
|
import { startWebServer, stopWebServer, type WebServerHandle } from "./web-server.js";
|
|
@@ -66,6 +66,8 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
66
66
|
const config = loadPluginConfig(overrides as any);
|
|
67
67
|
const toast = createToast(config);
|
|
68
68
|
|
|
69
|
+
setOpencodeClient(client);
|
|
70
|
+
|
|
69
71
|
const cerebroClient = new CerebroClient(config.connection.apiUrl, config.connection.apiKey, config);
|
|
70
72
|
|
|
71
73
|
try {
|
package/src/logger.ts
CHANGED
|
@@ -33,8 +33,12 @@ function getConfig() {
|
|
|
33
33
|
return cachedConfig;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function getLogFilePath(): string {
|
|
37
|
-
|
|
36
|
+
function getLogFilePath(sessionId?: string): string {
|
|
37
|
+
const base = getConfig().logging.logDir;
|
|
38
|
+
if (sessionId) {
|
|
39
|
+
return join(base, `cerebro-${sessionId}.log`);
|
|
40
|
+
}
|
|
41
|
+
return join(base, "cerebro.log");
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
function getMinLevel(): number {
|
|
@@ -109,7 +113,8 @@ function writeLog(level: string, message: string, fields?: Record<string, unknow
|
|
|
109
113
|
if (lvl < getMinLevel()) return;
|
|
110
114
|
|
|
111
115
|
const cfg = getConfig();
|
|
112
|
-
const
|
|
116
|
+
const sid = (fields?.sessionId ?? fields?.sessionID) as string | undefined;
|
|
117
|
+
const logFile = getLogFilePath(sid);
|
|
113
118
|
ensureLogDir(cfg.logging.logDir);
|
|
114
119
|
|
|
115
120
|
const now = new Date();
|
package/src/updater.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { openSync, closeSync, unlinkSync, statSync, writeSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
7
7
|
import { loadPluginConfig } from "./config.js";
|
|
8
|
+
import { logInfo, logDebug, logError } from "./logger.js";
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const require = createRequire(import.meta.url);
|
|
@@ -14,8 +15,12 @@ const require = createRequire(import.meta.url);
|
|
|
14
15
|
async function getLatestVersion(): Promise<string | null> {
|
|
15
16
|
try {
|
|
16
17
|
const { stdout } = await execFileAsync("npm", ["view", "@mingxy/cerebro", "version"], { timeout: 10000 });
|
|
18
|
+
logDebug("updater: fetched latest version", { version: stdout.trim() });
|
|
17
19
|
return stdout.trim();
|
|
18
|
-
} catch {
|
|
20
|
+
} catch {
|
|
21
|
+
logError("updater: failed to fetch latest version");
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
// ── Semantic version comparison ─────────────────────────────────────
|
|
@@ -45,6 +50,7 @@ function getInstallDir(): string {
|
|
|
45
50
|
|
|
46
51
|
async function installUpdate(targetDir: string): Promise<boolean> {
|
|
47
52
|
const tmpDir = mkdtempSync(join(tmpdir(), "cerebro-update-"));
|
|
53
|
+
logInfo("updater: downloading update", { targetDir, tmpDir });
|
|
48
54
|
try {
|
|
49
55
|
// 1. Download tgz
|
|
50
56
|
await execFileAsync("npm", ["pack", "@mingxy/cerebro@latest", "--pack-destination", tmpDir], { timeout: 60000 });
|
|
@@ -52,13 +58,18 @@ async function installUpdate(targetDir: string): Promise<boolean> {
|
|
|
52
58
|
// 2. Find the tgz file
|
|
53
59
|
const files = readdirSync(tmpDir);
|
|
54
60
|
const tgz = files.find(f => f.endsWith(".tgz"));
|
|
55
|
-
if (!tgz)
|
|
61
|
+
if (!tgz) {
|
|
62
|
+
logError("updater: no tgz found after npm pack", { tmpDir, files: files.join(",") });
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
56
65
|
|
|
57
66
|
// 3. Extract to target dir (strip the "package/" prefix from tgz)
|
|
58
67
|
await execFileAsync("tar", ["-xzf", join(tmpDir, tgz), "-C", targetDir, "--strip-components=1", "--no-same-owner", "--no-same-permissions"], { timeout: 30000 });
|
|
59
68
|
|
|
69
|
+
logInfo("updater: update installed successfully", { targetDir, tgz });
|
|
60
70
|
return true;
|
|
61
|
-
} catch {
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logError("updater: install failed", { error: err instanceof Error ? err.message : String(err) });
|
|
62
73
|
return false;
|
|
63
74
|
} finally {
|
|
64
75
|
try { rmSync(tmpDir, { recursive: true }); } catch {}
|
|
@@ -78,13 +89,18 @@ function acquireLock(): boolean {
|
|
|
78
89
|
const s = statSync(LOCK_FILE);
|
|
79
90
|
if (Date.now() - s.mtimeMs > STALE_LOCK_MS) {
|
|
80
91
|
unlinkSync(LOCK_FILE);
|
|
92
|
+
logDebug("updater: cleaned stale lock", { ageMs: Date.now() - s.mtimeMs });
|
|
81
93
|
}
|
|
82
94
|
} catch { /* lock file doesn't exist, normal */ }
|
|
83
95
|
|
|
84
96
|
lockFd = openSync(LOCK_FILE, "wx");
|
|
85
97
|
writeSync(lockFd, String(process.pid));
|
|
98
|
+
logDebug("updater: lock acquired", { pid: process.pid });
|
|
86
99
|
return true;
|
|
87
|
-
} catch {
|
|
100
|
+
} catch {
|
|
101
|
+
logDebug("updater: lock acquisition failed (another process updating?)");
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
function releaseLock(): void {
|
|
@@ -92,6 +108,7 @@ function releaseLock(): void {
|
|
|
92
108
|
try { closeSync(lockFd); } catch {}
|
|
93
109
|
lockFd = null;
|
|
94
110
|
try { unlinkSync(LOCK_FILE); } catch {}
|
|
111
|
+
logDebug("updater: lock released");
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
114
|
|
|
@@ -99,25 +116,38 @@ function releaseLock(): void {
|
|
|
99
116
|
|
|
100
117
|
export async function checkAndUpdate(tui: any, currentVersion: string): Promise<void> {
|
|
101
118
|
const config = loadPluginConfig();
|
|
102
|
-
if (!config.autoUpdate)
|
|
119
|
+
if (!config.autoUpdate) {
|
|
120
|
+
logDebug("updater: autoUpdate disabled, skipping");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logInfo("updater: checking for updates", { currentVersion });
|
|
103
125
|
|
|
104
126
|
const latest = await getLatestVersion();
|
|
105
127
|
if (!latest) return;
|
|
106
128
|
|
|
107
|
-
if (compareVersions(latest, currentVersion) <= 0)
|
|
129
|
+
if (compareVersions(latest, currentVersion) <= 0) {
|
|
130
|
+
logInfo("updater: already up to date", { currentVersion, latest });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logInfo("updater: new version available", { currentVersion, latest });
|
|
108
135
|
|
|
109
|
-
if (!acquireLock()) return;
|
|
136
|
+
if (!acquireLock()) return;
|
|
110
137
|
|
|
111
138
|
try {
|
|
112
139
|
const targetDir = getInstallDir();
|
|
113
140
|
const success = await installUpdate(targetDir);
|
|
114
141
|
|
|
115
142
|
if (success) {
|
|
143
|
+
logInfo("updater: update completed", { from: currentVersion, to: latest });
|
|
116
144
|
try {
|
|
117
145
|
tui?.showToast?.({
|
|
118
146
|
body: { message: `Cerebro updated to v${latest} — restart opencode to apply`, variant: "info" }
|
|
119
147
|
});
|
|
120
148
|
} catch {}
|
|
149
|
+
} else {
|
|
150
|
+
logError("updater: update failed", { targetDir });
|
|
121
151
|
}
|
|
122
152
|
} finally {
|
|
123
153
|
releaseLock();
|
package/src/web-server-child.ts
CHANGED
|
@@ -10,6 +10,32 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
|
|
13
|
+
// ── Lightweight file logger for child process ──────────────────────────
|
|
14
|
+
// Cannot import logger.ts (no opencodeClient + circular dependency risk).
|
|
15
|
+
// Writes to cerebro-web-child.log in the configured log directory.
|
|
16
|
+
|
|
17
|
+
const CHILD_LOG_FILE = path.join(
|
|
18
|
+
process.env.OMEM_LOG_DIR || path.join(os.homedir(), ".config", "cerebro", "logs"),
|
|
19
|
+
"cerebro-web-child.log",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function childLog(level: string, message: string, fields?: Record<string, unknown>): void {
|
|
23
|
+
try {
|
|
24
|
+
const dir = path.dirname(CHILD_LOG_FILE);
|
|
25
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const ts = now.toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
28
|
+
const parts = [`${level.padEnd(5)} ${ts} service=cerebro-web-child`];
|
|
29
|
+
if (fields) {
|
|
30
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
31
|
+
parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
parts.push(message);
|
|
35
|
+
fs.appendFileSync(CHILD_LOG_FILE, parts.join(" ") + "\n");
|
|
36
|
+
} catch { /* best effort */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
14
40
|
|
|
15
41
|
interface ChildConfig {
|
|
@@ -138,14 +164,14 @@ function startServer(config: ChildConfig): void {
|
|
|
138
164
|
try {
|
|
139
165
|
fs.writeFileSync(pidFilePath, String(process.pid));
|
|
140
166
|
} catch {
|
|
141
|
-
|
|
167
|
+
childLog("ERROR", "Failed to write PID file");
|
|
142
168
|
}
|
|
143
169
|
|
|
144
170
|
// 初始 touch 心跳文件
|
|
145
171
|
try {
|
|
146
172
|
fs.writeFileSync(heartbeatFilePath, "");
|
|
147
173
|
} catch {
|
|
148
|
-
|
|
174
|
+
childLog("ERROR", "Failed to create heartbeat file");
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
server = http.createServer(
|
|
@@ -199,15 +225,13 @@ function startServer(config: ChildConfig): void {
|
|
|
199
225
|
);
|
|
200
226
|
|
|
201
227
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
202
|
-
|
|
228
|
+
childLog("ERROR", "Server error", { error: err.message });
|
|
203
229
|
process.send?.({ type: "error", message: err.message });
|
|
204
230
|
cleanup();
|
|
205
231
|
});
|
|
206
232
|
|
|
207
233
|
server.listen(port, "127.0.0.1", () => {
|
|
208
|
-
|
|
209
|
-
`[cerebro:web-child] Server listening on http://localhost:${port}`,
|
|
210
|
-
);
|
|
234
|
+
childLog("INFO", "Server listening", { port });
|
|
211
235
|
process.send?.({ type: "ready", port });
|
|
212
236
|
});
|
|
213
237
|
|
|
@@ -216,9 +240,7 @@ function startServer(config: ChildConfig): void {
|
|
|
216
240
|
try {
|
|
217
241
|
const stat = fs.statSync(heartbeatFilePath);
|
|
218
242
|
if (Date.now() - stat.mtimeMs > 60_000) {
|
|
219
|
-
|
|
220
|
-
"[cerebro:web-child] Heartbeat expired, shutting down",
|
|
221
|
-
);
|
|
243
|
+
childLog("INFO", "Heartbeat expired, shutting down");
|
|
222
244
|
cleanup();
|
|
223
245
|
}
|
|
224
246
|
} catch {
|
package/src/web-server.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { logInfo, logWarn, logError } from "./logger.js";
|
|
13
14
|
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
16
|
const __dirname = path.dirname(__filename);
|
|
@@ -75,7 +76,7 @@ export async function startWebServer(
|
|
|
75
76
|
|
|
76
77
|
// ── Step 1: 检查端口是否已有 cerebro server ──
|
|
77
78
|
if (await probeExistingServer(port)) {
|
|
78
|
-
|
|
79
|
+
logInfo("web-server: reusing existing server", { port });
|
|
79
80
|
return createHandle(port, heartbeatFilePath);
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -87,9 +88,7 @@ export async function startWebServer(
|
|
|
87
88
|
// 有其他进程正在 fork 或运行,等待 200ms 后重试
|
|
88
89
|
await new Promise((r) => setTimeout(r, 200));
|
|
89
90
|
if (await probeExistingServer(port)) {
|
|
90
|
-
|
|
91
|
-
`[cerebro:web] Reusing server after PID check on port ${port}`,
|
|
92
|
-
);
|
|
91
|
+
logInfo("web-server: reusing server after PID check", { port });
|
|
93
92
|
return createHandle(port, heartbeatFilePath);
|
|
94
93
|
}
|
|
95
94
|
} else {
|
|
@@ -101,15 +100,11 @@ export async function startWebServer(
|
|
|
101
100
|
// ── Step 3: 检查 web 目录 ──
|
|
102
101
|
const webDir = path.resolve(__dirname, "..", "web");
|
|
103
102
|
if (!fs.existsSync(webDir)) {
|
|
104
|
-
|
|
105
|
-
`[cerebro:web] Web directory not found: ${webDir}, skipping server start`,
|
|
106
|
-
);
|
|
103
|
+
logWarn("web-server: web directory not found, skipping", { webDir });
|
|
107
104
|
return null;
|
|
108
105
|
}
|
|
109
106
|
if (!fs.existsSync(path.join(webDir, "index.html"))) {
|
|
110
|
-
|
|
111
|
-
`[cerebro:web] index.html not found in ${webDir}, skipping server start`,
|
|
112
|
-
);
|
|
107
|
+
logWarn("web-server: index.html not found, skipping", { webDir });
|
|
113
108
|
return null;
|
|
114
109
|
}
|
|
115
110
|
|
|
@@ -119,11 +114,18 @@ export async function startWebServer(
|
|
|
119
114
|
} catch { /* ignore */ }
|
|
120
115
|
|
|
121
116
|
// ── Step 5: Fork 子进程 ──
|
|
122
|
-
|
|
117
|
+
// TS runtime: .ts exists; compiled: .js exists
|
|
118
|
+
const childTs = path.resolve(__dirname, "web-server-child.ts");
|
|
119
|
+
const childJs = path.resolve(__dirname, "web-server-child.js");
|
|
120
|
+
const childPath = fs.existsSync(childTs) ? childTs : childJs;
|
|
123
121
|
|
|
122
|
+
// Use the same executor as the parent process (tsx, node, etc.)
|
|
123
|
+
// so the child can run .ts files. Also inherit execArgv (--import tsx, etc.)
|
|
124
124
|
const child = fork(childPath, [], {
|
|
125
125
|
detached: true,
|
|
126
126
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
127
|
+
execPath: process.execPath,
|
|
128
|
+
execArgv: process.execArgv,
|
|
127
129
|
});
|
|
128
130
|
|
|
129
131
|
// Drain stdout/stderr to prevent pipe buffer from blocking the child
|
|
@@ -161,17 +163,13 @@ export async function startWebServer(
|
|
|
161
163
|
});
|
|
162
164
|
|
|
163
165
|
if (!ready) {
|
|
164
|
-
|
|
165
|
-
`[cerebro:web] Failed to start web server child process on port ${port}`,
|
|
166
|
-
);
|
|
166
|
+
logError("web-server: failed to start child process", { port });
|
|
167
167
|
try { child.kill(); } catch { /* ignore */ }
|
|
168
168
|
try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
169
169
|
return null;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
`[cerebro:web] Web server child process started on port ${port}`,
|
|
174
|
-
);
|
|
172
|
+
logInfo("web-server: child process started", { port, pid: child.pid });
|
|
175
173
|
return createHandle(port, heartbeatFilePath);
|
|
176
174
|
}
|
|
177
175
|
|