@mingxy/cerebro 1.16.9 → 1.17.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.
Files changed (59) hide show
  1. package/package.json +6 -1
  2. package/src/config.ts +19 -4
  3. package/src/hooks.ts +25 -13
  4. package/src/index.ts +30 -0
  5. package/src/web-server.ts +180 -0
  6. package/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  7. package/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  8. package/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  9. package/web/assets/index-B-0ucKQF.js +119 -0
  10. package/web/assets/index-Dxd5Um3O.css +1 -0
  11. package/web/favicon.svg +1 -0
  12. package/web/icons.svg +24 -0
  13. package/web/index.html +15 -0
  14. package/.omo/evidence/f1-verification.txt +0 -44
  15. package/INJECTION_FLOW.md +0 -434
  16. package/cerebro.example.jsonc +0 -72
  17. package/dist/client.d.ts +0 -165
  18. package/dist/client.d.ts.map +0 -1
  19. package/dist/client.js +0 -222
  20. package/dist/client.js.map +0 -1
  21. package/dist/config.d.ts +0 -46
  22. package/dist/config.d.ts.map +0 -1
  23. package/dist/config.js +0 -201
  24. package/dist/config.js.map +0 -1
  25. package/dist/hooks.d.ts +0 -41
  26. package/dist/hooks.d.ts.map +0 -1
  27. package/dist/hooks.js +0 -1066
  28. package/dist/hooks.js.map +0 -1
  29. package/dist/index.d.ts +0 -11
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -118
  32. package/dist/index.js.map +0 -1
  33. package/dist/keywords.d.ts +0 -3
  34. package/dist/keywords.d.ts.map +0 -1
  35. package/dist/keywords.js +0 -21
  36. package/dist/keywords.js.map +0 -1
  37. package/dist/logger.d.ts +0 -5
  38. package/dist/logger.d.ts.map +0 -1
  39. package/dist/logger.js +0 -62
  40. package/dist/logger.js.map +0 -1
  41. package/dist/privacy.d.ts +0 -3
  42. package/dist/privacy.d.ts.map +0 -1
  43. package/dist/privacy.js +0 -10
  44. package/dist/privacy.js.map +0 -1
  45. package/dist/tags.d.ts +0 -3
  46. package/dist/tags.d.ts.map +0 -1
  47. package/dist/tags.js +0 -13
  48. package/dist/tags.js.map +0 -1
  49. package/dist/tools.d.ts +0 -209
  50. package/dist/tools.d.ts.map +0 -1
  51. package/dist/tools.js +0 -344
  52. package/dist/tools.js.map +0 -1
  53. package/dist/tui.d.ts +0 -7
  54. package/dist/tui.d.ts.map +0 -1
  55. package/dist/tui.js +0 -63
  56. package/dist/tui.js.map +0 -1
  57. package/mingxy-omem-0.1.6.tgz +0 -0
  58. package/schema.json +0 -225
  59. package/tsconfig.json +0 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro",
3
- "version": "1.16.9",
3
+ "version": "1.17.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",
@@ -18,6 +18,10 @@
18
18
  "server",
19
19
  "tui"
20
20
  ],
21
+ "scripts": {
22
+ "build:web": "bash ../../scripts/build-plugin-web.sh",
23
+ "prepublishOnly": "npm run build:web && npx tsc --noEmit"
24
+ },
21
25
  "keywords": [
22
26
  "opencode",
23
27
  "memory",
@@ -28,6 +32,7 @@
28
32
  "project-isolation"
29
33
  ],
30
34
  "author": "mingxy-cerebro",
35
+ "files": ["src/", "web/"],
31
36
  "license": "Apache-2.0",
32
37
  "homepage": "https://github.com/mingxy-cerebro/cerebro-server",
33
38
  "repository": {
package/src/config.ts CHANGED
@@ -38,6 +38,10 @@ export interface OmemPluginConfig {
38
38
  ui: {
39
39
  toastDelayMs: number;
40
40
  };
41
+ web?: {
42
+ enabled?: boolean;
43
+ port?: number;
44
+ };
41
45
  profile?: {
42
46
  ttlMs?: number;
43
47
  };
@@ -81,6 +85,9 @@ const DEFAULTS: OmemPluginConfig = {
81
85
  ui: {
82
86
  toastDelayMs: 7000,
83
87
  },
88
+ web: {
89
+ enabled: true,
90
+ },
84
91
  profile: {
85
92
  ttlMs: 300000,
86
93
  },
@@ -164,6 +171,7 @@ function deepMerge(base: OmemPluginConfig, overrides: Partial<OmemPluginConfig>)
164
171
  logging: { ...base.logging, ...overrides.logging },
165
172
  ui: { ...base.ui, ...overrides.ui },
166
173
  };
174
+ result.web = { ...base.web!, ...overrides.web };
167
175
  result.profile = { ...base.profile!, ...overrides.profile };
168
176
  if (overrides.agentMemoryPolicy) result.agentMemoryPolicy = overrides.agentMemoryPolicy;
169
177
  if (overrides.defaultPolicy) result.defaultPolicy = overrides.defaultPolicy;
@@ -227,7 +235,12 @@ export function loadPluginConfig(overrides?: Partial<OmemPluginConfig>): OmemPlu
227
235
  configLog("config.json load failed, using defaults", { error: String(e) });
228
236
  }
229
237
 
230
- // Apply environment variable overrides (flat OMEM_* → nested paths)
238
+ // Apply explicit overrides (from opencode.json)
239
+ if (overrides) {
240
+ config = deepMerge(config, overrides);
241
+ }
242
+
243
+ // Apply environment variable overrides last — env vars have highest priority
231
244
  if (process.env.OMEM_API_URL) config.connection.apiUrl = process.env.OMEM_API_URL;
232
245
  if (process.env.OMEM_API_KEY) config.connection.apiKey = process.env.OMEM_API_KEY;
233
246
  if (process.env.OMEM_REQUEST_TIMEOUT_MS) {
@@ -246,9 +259,11 @@ export function loadPluginConfig(overrides?: Partial<OmemPluginConfig>): OmemPlu
246
259
  config.recall.maxRecallResults = parseInt(process.env.OMEM_MAX_RECALL_RESULTS, 10) || DEFAULTS.recall.maxRecallResults;
247
260
  }
248
261
 
249
- // Apply explicit overrides (from opencode.json)
250
- if (overrides) {
251
- config = deepMerge(config, overrides);
262
+ if (process.env.OMEM_WEB_ENABLED === "false" || process.env.OMEM_WEB_ENABLED === "0") {
263
+ config.web = { ...config.web!, enabled: false };
264
+ }
265
+ if (process.env.OMEM_LOCAL_PORT) {
266
+ config.web = { ...config.web!, port: parseInt(process.env.OMEM_LOCAL_PORT, 10) || DEFAULTS.web!.port };
252
267
  }
253
268
 
254
269
  // Expand ~ to home directory in logDir
package/src/hooks.ts CHANGED
@@ -494,24 +494,36 @@ export function autoRecallHook(client: CerebroClient, containerTags: string[], t
494
494
  },
495
495
  ): Promise<string | undefined> => {
496
496
  try {
497
+ const memoryLookup = new Map<string, SearchResult>();
498
+ if (shouldRecallRes.memories) {
499
+ for (const r of shouldRecallRes.memories) {
500
+ memoryLookup.set(r.memory.id, r);
501
+ }
502
+ }
497
503
  const items = clustered
498
504
  ? [
499
505
  ...clustered.cluster_summaries.flatMap((cs) =>
500
- cs.key_memories.map((mem) => ({
506
+ cs.key_memories.map((mem) => {
507
+ const sr = memoryLookup.get(mem.id ?? "");
508
+ return {
509
+ memory_id: mem.id ?? "",
510
+ score: cs.relevance_score,
511
+ refine_relevance: sr?.refine_relevance,
512
+ refine_reasoning: sr?.refine_reasoning,
513
+ is_kept: opts.injectedMemoryIds.includes(mem.id ?? ""),
514
+ };
515
+ })
516
+ ),
517
+ ...clustered.standalone_memories.map((mem) => {
518
+ const sr = memoryLookup.get(mem.id ?? "");
519
+ return {
501
520
  memory_id: mem.id ?? "",
502
- score: cs.relevance_score,
503
- refine_relevance: undefined,
504
- refine_reasoning: undefined,
521
+ score: sr?.score ?? 0,
522
+ refine_relevance: sr?.refine_relevance,
523
+ refine_reasoning: sr?.refine_reasoning,
505
524
  is_kept: opts.injectedMemoryIds.includes(mem.id ?? ""),
506
- }))
507
- ),
508
- ...clustered.standalone_memories.map((mem) => ({
509
- memory_id: mem.id ?? "",
510
- score: 0,
511
- refine_relevance: undefined,
512
- refine_reasoning: undefined,
513
- is_kept: opts.injectedMemoryIds.includes(mem.id ?? ""),
514
- })),
525
+ };
526
+ }),
515
527
  ]
516
528
  : [
517
529
  ...(shouldRecallRes.memories?.map((r) => ({
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@ 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";
6
7
  import { CerebroClient } from "./client.js";
7
8
  import { autoRecallHook, autocontinueHook, compactingHook, keywordDetectionHook, sessionIdleHook, showToast as hooksShowToast } from "./hooks.js";
8
9
  import { getUserTag, getProjectTag } from "./tags.js";
9
10
  import { buildTools } from "./tools.js";
10
11
  import { logInfo, logDebug, logError } from "./logger.js";
11
12
  import { loadPluginConfig } from "./config.js";
13
+ import { startWebServer, stopWebServer } from "./web-server.js";
12
14
 
13
15
  const __filename = fileURLToPath(import.meta.url);
14
16
  const __dirname = dirname(__filename);
@@ -103,6 +105,34 @@ const OmemPlugin: Plugin = async (input) => {
103
105
 
104
106
  const recallHook = autoRecallHook(cerebroClient, containerTags, tui, config, () => cachedAgentName || agentId, directory);
105
107
 
108
+ let webServer: Server | null = null;
109
+ const webEnabled = config.web?.enabled !== false;
110
+ if (webEnabled) {
111
+ try {
112
+ webServer = await startWebServer({
113
+ apiUrl: config.connection.apiUrl,
114
+ port: config.web?.port,
115
+ });
116
+ if (webServer) {
117
+ const addr = webServer.address();
118
+ const actualPort = typeof addr === "object" && addr ? addr.port : config.web?.port || 5212;
119
+ hooksShowToast(tui, "🌐 Cerebro Web", `http://localhost:${actualPort}`, "info", 8000);
120
+ logInfo(`Web UI available at http://localhost:${actualPort}`);
121
+ }
122
+ } catch (err) {
123
+ logError(`Web server start failed: ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ }
126
+
127
+ const shutdown = async () => {
128
+ if (webServer) {
129
+ await stopWebServer(webServer);
130
+ webServer = null;
131
+ }
132
+ };
133
+ process.on("SIGTERM", shutdown);
134
+ process.on("SIGINT", shutdown);
135
+
106
136
  return {
107
137
  config: async (cfg: any) => {
108
138
  cfg.command ??= {};
@@ -0,0 +1,180 @@
1
+ import * as http from "node:http";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────────
10
+
11
+ export interface WebServerConfig {
12
+ apiUrl: string;
13
+ port?: number;
14
+ }
15
+
16
+ // ── MIME map ─────────────────────────────────────────────────────────────
17
+
18
+ const MIME_TYPES: Record<string, string> = {
19
+ ".html": "text/html; charset=utf-8",
20
+ ".js": "application/javascript; charset=utf-8",
21
+ ".mjs": "application/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".json": "application/json; charset=utf-8",
24
+ ".svg": "image/svg+xml",
25
+ ".png": "image/png",
26
+ ".jpg": "image/jpeg",
27
+ ".jpeg": "image/jpeg",
28
+ ".gif": "image/gif",
29
+ ".ico": "image/x-icon",
30
+ ".woff": "font/woff",
31
+ ".woff2": "font/woff2",
32
+ ".ttf": "font/ttf",
33
+ ".eot": "application/vnd.ms-fontobject",
34
+ ".webp": "image/webp",
35
+ ".webmanifest": "application/manifest+json",
36
+ ".map": "application/json",
37
+ ".txt": "text/plain; charset=utf-8",
38
+ };
39
+
40
+ function getMimeType(ext: string): string {
41
+ return MIME_TYPES[ext] || "application/octet-stream";
42
+ }
43
+
44
+ // ── Safe path resolver (prevent traversal) ───────────────────────────────
45
+
46
+ function resolveSafe(baseDir: string, pathname: string): string | null {
47
+ const resolved = path.resolve(baseDir, pathname);
48
+ if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
49
+ return null;
50
+ }
51
+ return resolved;
52
+ }
53
+
54
+ // ── Start / Stop ─────────────────────────────────────────────────────────
55
+
56
+ export function startWebServer(config: WebServerConfig): Promise<http.Server | null> {
57
+ return new Promise((resolve) => {
58
+ const webDir = path.resolve(__dirname, "..", "web");
59
+
60
+ // Check web directory exists
61
+ if (!fs.existsSync(webDir)) {
62
+ console.warn(`[cerebro:web] Web directory not found: ${webDir}, skipping server start`);
63
+ resolve(null);
64
+ return;
65
+ }
66
+
67
+ const indexPath = path.join(webDir, "index.html");
68
+ if (!fs.existsSync(indexPath)) {
69
+ console.warn(`[cerebro:web] index.html not found in ${webDir}, skipping server start`);
70
+ resolve(null);
71
+ return;
72
+ }
73
+
74
+ const port = config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || 5212;
75
+
76
+ const server = http.createServer(
77
+ (req: http.IncomingMessage, res: http.ServerResponse) => {
78
+ // Only handle GET / HEAD
79
+ if (req.method !== "GET" && req.method !== "HEAD") {
80
+ res.writeHead(405, { "Content-Type": "text/plain" });
81
+ res.end("Method Not Allowed");
82
+ return;
83
+ }
84
+
85
+ // Parse URL, strip query string
86
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
87
+ const pathname = decodeURIComponent(url.pathname);
88
+
89
+ // Resolve safe file path
90
+ const safePath = resolveSafe(webDir, pathname);
91
+
92
+ if (!safePath) {
93
+ res.writeHead(403, { "Content-Type": "text/plain" });
94
+ res.end("Forbidden");
95
+ return;
96
+ }
97
+
98
+ // Try to serve the file directly
99
+ fs.stat(safePath, (statErr, stats) => {
100
+ if (!statErr && stats.isFile()) {
101
+ serveFile(res, safePath, config.apiUrl);
102
+ return;
103
+ }
104
+
105
+ // SPA fallback: serve index.html for non-file paths
106
+ fs.stat(indexPath, (idxErr, idxStats) => {
107
+ if (idxErr || !idxStats.isFile()) {
108
+ res.writeHead(404, { "Content-Type": "text/plain" });
109
+ res.end("Not Found");
110
+ return;
111
+ }
112
+ serveFile(res, indexPath, config.apiUrl);
113
+ });
114
+ });
115
+ },
116
+ );
117
+
118
+ server.on("error", (err: NodeJS.ErrnoException) => {
119
+ if (err.code === "EADDRINUSE") {
120
+ console.warn(`[cerebro:web] Port ${port} already in use, web server not started`);
121
+ } else {
122
+ console.warn(`[cerebro:web] Server error: ${err.message}`);
123
+ }
124
+ resolve(null);
125
+ });
126
+
127
+ server.listen(port, "127.0.0.1", () => {
128
+ const addr = server.address();
129
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
130
+ console.log(`[cerebro:web] Static server listening on http://localhost:${actualPort}`);
131
+ resolve(server);
132
+ });
133
+ });
134
+ }
135
+
136
+ // ── File serving ─────────────────────────────────────────────────────────
137
+
138
+ function serveFile(
139
+ res: http.ServerResponse,
140
+ filePath: string,
141
+ apiUrl: string,
142
+ ): void {
143
+ const ext = path.extname(filePath).toLowerCase();
144
+ const contentType = getMimeType(ext);
145
+
146
+ fs.readFile(filePath, (err, data) => {
147
+ if (err) {
148
+ res.writeHead(500, { "Content-Type": "text/plain" });
149
+ res.end("Internal Server Error");
150
+ return;
151
+ }
152
+
153
+ let body: Buffer | string = data;
154
+
155
+ // Config injection: replace placeholder in index.html
156
+ if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
157
+ body = data.toString("utf-8").replace(/__OMEM_API_URL__/g, () => apiUrl);
158
+ }
159
+
160
+ res.writeHead(200, {
161
+ "Content-Type": contentType,
162
+ "Cache-Control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400",
163
+ });
164
+ res.end(body);
165
+ });
166
+ }
167
+
168
+ // ── Graceful shutdown ────────────────────────────────────────────────────
169
+
170
+ export function stopWebServer(server: http.Server): Promise<void> {
171
+ return new Promise((resolve) => {
172
+ server.closeAllConnections?.();
173
+ const timer = setTimeout(resolve, 3000);
174
+ server.close(() => {
175
+ clearTimeout(timer);
176
+ console.log("[cerebro:web] Server stopped");
177
+ resolve();
178
+ });
179
+ });
180
+ }