@mingxy/cerebro 1.19.3 → 1.20.1
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 +4 -1
- package/src/hooks.ts +9 -2
- package/src/index.ts +10 -5
- package/src/logger.ts +165 -66
- package/src/updater.ts +155 -0
- package/src/web-server-child.ts +267 -0
- package/src/web-server.ts +161 -144
- 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.
|
|
3
|
+
"version": "1.20.1",
|
|
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
|
|
|
@@ -174,7 +177,7 @@ function configLog(message: string, fields?: Record<string, unknown>, level: str
|
|
|
174
177
|
if (lvl < CONFIGURED_MIN_LEVEL) return;
|
|
175
178
|
try {
|
|
176
179
|
const logDir = join(homedir(), ".config", "cerebro", "logs");
|
|
177
|
-
const logPath = join(logDir, "
|
|
180
|
+
const logPath = join(logDir, "cerebro.log");
|
|
178
181
|
const ts = new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
179
182
|
const parts = [`${level.padEnd(5)} ${ts} service=cerebro ${message}`];
|
|
180
183
|
if (fields) {
|
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:
|
|
375
|
-
llm_confidence:
|
|
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,15 +3,15 @@ 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";
|
|
10
9
|
import { getUserTag, getProjectTag } from "./tags.js";
|
|
11
10
|
import { buildTools } from "./tools.js";
|
|
12
|
-
import { logInfo, logDebug, logError } from "./logger.js";
|
|
11
|
+
import { logInfo, logDebug, logError, setOpencodeClient } from "./logger.js";
|
|
13
12
|
import { loadPluginConfig, resolveAgentPolicy } from "./config.js";
|
|
14
|
-
import {
|
|
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);
|
|
@@ -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 {
|
|
@@ -103,7 +105,7 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
103
105
|
|
|
104
106
|
const chatMessageRecall = chatMessageRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
|
|
105
107
|
|
|
106
|
-
let webServer:
|
|
108
|
+
let webServer: WebServerHandle | null = null;
|
|
107
109
|
const webEnabled = config.web?.enabled !== false;
|
|
108
110
|
let webPort: number | undefined;
|
|
109
111
|
if (webEnabled) {
|
|
@@ -125,9 +127,12 @@ const OmemPlugin: Plugin = async (input) => {
|
|
|
125
127
|
if (webPort) {
|
|
126
128
|
toast(tui, `🧠 Cerebro Connected · v${pluginVersion}`, `🌐 Open in browser http://localhost:${webPort}`, "success");
|
|
127
129
|
} else {
|
|
128
|
-
toast(tui,
|
|
130
|
+
toast(tui, `🧠 Cerebro Connected · v${pluginVersion}`, "No web server", "success");
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
// Auto-update check (fire-and-forget, non-blocking)
|
|
134
|
+
checkAndUpdate(tui, pluginVersion).catch(() => {});
|
|
135
|
+
|
|
131
136
|
const shutdown = async () => {
|
|
132
137
|
try {
|
|
133
138
|
if (webServer) {
|
package/src/logger.ts
CHANGED
|
@@ -1,66 +1,165 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
export function
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(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");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getMinLevel(): number {
|
|
45
|
+
return LEVEL_MAP[getConfig().logging.logLevel] ?? LEVEL_MAP.INFO;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLogEnabled(): boolean {
|
|
49
|
+
return getConfig().logging.logEnabled;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Opencode client for dual-track logging ────────────────────────────
|
|
53
|
+
|
|
54
|
+
let opencodeClient: any = null;
|
|
55
|
+
|
|
56
|
+
export function setOpencodeClient(client: any): void {
|
|
57
|
+
opencodeClient = client;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Log file rotation (5 MB threshold) ────────────────────────────────
|
|
61
|
+
// NOTE: multi-window concurrent rotate is a known limitation — the last
|
|
62
|
+
// writer to rename wins; earlier writers will create a fresh file.
|
|
63
|
+
|
|
64
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
65
|
+
|
|
66
|
+
function rotateIfNeeded(logFile: string): void {
|
|
67
|
+
try {
|
|
68
|
+
const s = statSync(logFile);
|
|
69
|
+
if (s.size > MAX_LOG_SIZE) {
|
|
70
|
+
renameSync(logFile, logFile.replace(".log", ".old.log"));
|
|
71
|
+
}
|
|
72
|
+
} catch { /* file doesn't exist yet, first write */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Startup cleanup (delete logs older than 7 days) ───────────────────
|
|
76
|
+
|
|
77
|
+
const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
78
|
+
|
|
79
|
+
function cleanupOldLogs(): void {
|
|
80
|
+
const logDir = getConfig().logging.logDir;
|
|
81
|
+
try {
|
|
82
|
+
const files = readdirSync(logDir);
|
|
83
|
+
const cutoff = Date.now() - LOG_MAX_AGE_MS;
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
if (!f.endsWith(".log") && !f.endsWith(".old.log")) continue;
|
|
86
|
+
const fp = join(logDir, f);
|
|
87
|
+
try {
|
|
88
|
+
const s = statSync(fp);
|
|
89
|
+
if (s.mtimeMs < cutoff) unlinkSync(fp);
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Run cleanup once at module load
|
|
96
|
+
cleanupOldLogs();
|
|
97
|
+
|
|
98
|
+
// ── Core logging ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
let lastLogTime = Date.now();
|
|
101
|
+
|
|
102
|
+
function ensureLogDir(logDir: string): void {
|
|
103
|
+
if (!existsSync(logDir)) {
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(logDir, { recursive: true });
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeLog(level: string, message: string, fields?: Record<string, unknown>): void {
|
|
111
|
+
if (!isLogEnabled()) return;
|
|
112
|
+
const lvl = LEVEL_MAP[level] ?? 0;
|
|
113
|
+
if (lvl < getMinLevel()) return;
|
|
114
|
+
|
|
115
|
+
const cfg = getConfig();
|
|
116
|
+
const sid = (fields?.sessionId ?? fields?.sessionID) as string | undefined;
|
|
117
|
+
const logFile = getLogFilePath(sid);
|
|
118
|
+
ensureLogDir(cfg.logging.logDir);
|
|
119
|
+
|
|
120
|
+
const now = new Date();
|
|
121
|
+
const nowMs = now.getTime();
|
|
122
|
+
const delta = ((nowMs - lastLogTime) / 1000).toFixed(2);
|
|
123
|
+
lastLogTime = nowMs;
|
|
124
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
125
|
+
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
126
|
+
const parts = [`${level.padEnd(5)} ${ts} +${delta}s service=cerebro`];
|
|
127
|
+
if (fields) {
|
|
128
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
129
|
+
const val = typeof v === "string" ? v : JSON.stringify(v);
|
|
130
|
+
parts.push(`${k}=${val}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
parts.push(message);
|
|
134
|
+
|
|
135
|
+
// Track 1: file
|
|
136
|
+
try {
|
|
137
|
+
rotateIfNeeded(logFile);
|
|
138
|
+
appendFileSync(logFile, parts.join(" ") + "\n");
|
|
139
|
+
} catch {}
|
|
140
|
+
|
|
141
|
+
// Track 2: opencode client
|
|
142
|
+
try {
|
|
143
|
+
opencodeClient?.app?.log({
|
|
144
|
+
body: { service: "cerebro", level: level.toLowerCase(), message, extra: fields },
|
|
145
|
+
});
|
|
146
|
+
} catch { /* opencode client not available, skip */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export function logInfo(message: string, fields?: Record<string, unknown>): void {
|
|
152
|
+
writeLog("INFO", message, fields);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function logWarn(message: string, fields?: Record<string, unknown>): void {
|
|
156
|
+
writeLog("WARN", message, fields);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function logError(message: string, fields?: Record<string, unknown>): void {
|
|
160
|
+
writeLog("ERROR", message, fields);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function logDebug(message: string, fields?: Record<string, unknown>): void {
|
|
164
|
+
writeLog("DEBUG", message, fields);
|
|
165
|
+
}
|
package/src/updater.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
import { logInfo, logDebug, logError } from "./logger.js";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
// ── Version fetching ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
async function getLatestVersion(): Promise<string | null> {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileAsync("npm", ["view", "@mingxy/cerebro", "version"], { timeout: 10000 });
|
|
18
|
+
logDebug("updater: fetched latest version", { version: stdout.trim() });
|
|
19
|
+
return stdout.trim();
|
|
20
|
+
} catch {
|
|
21
|
+
logError("updater: failed to fetch latest version");
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Semantic version comparison ─────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function compareVersions(a: string, b: string): number {
|
|
29
|
+
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
30
|
+
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
|
|
33
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Install dir detection ───────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function getInstallDir(): string {
|
|
41
|
+
try {
|
|
42
|
+
const pkgPath = require.resolve("@mingxy/cerebro/package.json");
|
|
43
|
+
return dirname(pkgPath);
|
|
44
|
+
} catch {
|
|
45
|
+
return join(homedir(), ".cache", "opencode", "packages", "@mingxy", "cerebro");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Install update via npm pack + tar ───────────────────────────────
|
|
50
|
+
|
|
51
|
+
async function installUpdate(targetDir: string): Promise<boolean> {
|
|
52
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "cerebro-update-"));
|
|
53
|
+
logInfo("updater: downloading update", { targetDir, tmpDir });
|
|
54
|
+
try {
|
|
55
|
+
// 1. Download tgz
|
|
56
|
+
await execFileAsync("npm", ["pack", "@mingxy/cerebro@latest", "--pack-destination", tmpDir], { timeout: 60000 });
|
|
57
|
+
|
|
58
|
+
// 2. Find the tgz file
|
|
59
|
+
const files = readdirSync(tmpDir);
|
|
60
|
+
const tgz = files.find(f => f.endsWith(".tgz"));
|
|
61
|
+
if (!tgz) {
|
|
62
|
+
logError("updater: no tgz found after npm pack", { tmpDir, files: files.join(",") });
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. Extract to target dir (strip the "package/" prefix from tgz)
|
|
67
|
+
await execFileAsync("tar", ["-xzf", join(tmpDir, tgz), "-C", targetDir, "--strip-components=1", "--no-same-owner", "--no-same-permissions"], { timeout: 30000 });
|
|
68
|
+
|
|
69
|
+
logInfo("updater: update installed successfully", { targetDir, tgz });
|
|
70
|
+
return true;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logError("updater: install failed", { error: err instanceof Error ? err.message : String(err) });
|
|
73
|
+
return false;
|
|
74
|
+
} finally {
|
|
75
|
+
try { rmSync(tmpDir, { recursive: true }); } catch {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── File lock with stale lock cleanup ───────────────────────────────
|
|
80
|
+
|
|
81
|
+
const LOCK_FILE = join(tmpdir(), "cerebro-update.lock");
|
|
82
|
+
const STALE_LOCK_MS = 5 * 60 * 1000; // 5 minutes
|
|
83
|
+
let lockFd: number | null = null;
|
|
84
|
+
|
|
85
|
+
function acquireLock(): boolean {
|
|
86
|
+
try {
|
|
87
|
+
// Clean up stale lock first
|
|
88
|
+
try {
|
|
89
|
+
const s = statSync(LOCK_FILE);
|
|
90
|
+
if (Date.now() - s.mtimeMs > STALE_LOCK_MS) {
|
|
91
|
+
unlinkSync(LOCK_FILE);
|
|
92
|
+
logDebug("updater: cleaned stale lock", { ageMs: Date.now() - s.mtimeMs });
|
|
93
|
+
}
|
|
94
|
+
} catch { /* lock file doesn't exist, normal */ }
|
|
95
|
+
|
|
96
|
+
lockFd = openSync(LOCK_FILE, "wx");
|
|
97
|
+
writeSync(lockFd, String(process.pid));
|
|
98
|
+
logDebug("updater: lock acquired", { pid: process.pid });
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
logDebug("updater: lock acquisition failed (another process updating?)");
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function releaseLock(): void {
|
|
107
|
+
if (lockFd !== null) {
|
|
108
|
+
try { closeSync(lockFd); } catch {}
|
|
109
|
+
lockFd = null;
|
|
110
|
+
try { unlinkSync(LOCK_FILE); } catch {}
|
|
111
|
+
logDebug("updater: lock released");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Main entry ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export async function checkAndUpdate(tui: any, currentVersion: string): Promise<void> {
|
|
118
|
+
const config = loadPluginConfig();
|
|
119
|
+
if (!config.autoUpdate) {
|
|
120
|
+
logDebug("updater: autoUpdate disabled, skipping");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logInfo("updater: checking for updates", { currentVersion });
|
|
125
|
+
|
|
126
|
+
const latest = await getLatestVersion();
|
|
127
|
+
if (!latest) return;
|
|
128
|
+
|
|
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 });
|
|
135
|
+
|
|
136
|
+
if (!acquireLock()) return;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const targetDir = getInstallDir();
|
|
140
|
+
const success = await installUpdate(targetDir);
|
|
141
|
+
|
|
142
|
+
if (success) {
|
|
143
|
+
logInfo("updater: update completed", { from: currentVersion, to: latest });
|
|
144
|
+
try {
|
|
145
|
+
tui?.showToast?.({
|
|
146
|
+
body: { message: `Cerebro updated to v${latest} — restart opencode to apply`, variant: "info" }
|
|
147
|
+
});
|
|
148
|
+
} catch {}
|
|
149
|
+
} else {
|
|
150
|
+
logError("updater: update failed", { targetDir });
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
releaseLock();
|
|
154
|
+
}
|
|
155
|
+
}
|