@mingxy/cerebro 1.10.9 → 1.10.10

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.10.9",
3
+ "version": "1.10.10",
4
4
  "description": "Cerebro persistent memory plugin for OpenCode — auto-recall, auto-capture, 9 memory tools with clustering",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -144,6 +144,25 @@ function deepMerge(base: OmemPluginConfig, overrides: Partial<OmemPluginConfig>)
144
144
 
145
145
  // ── Load config ──────────────────────────────────────────────────────
146
146
 
147
+ /** File-only logger for config.ts (cannot import logger.ts due to circular dependency). */
148
+ function configLog(message: string, fields?: Record<string, unknown>): void {
149
+ try {
150
+ const logDir = join(homedir(), ".config", "cerebro", "logs");
151
+ const logPath = join(logDir, "plugin.log");
152
+ const ts = new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
153
+ const parts = [`WARN ${ts} service=cerebro ${message}`];
154
+ if (fields) {
155
+ for (const [k, v] of Object.entries(fields)) {
156
+ parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
157
+ }
158
+ }
159
+ mkdirSync(logDir, { recursive: true });
160
+ appendFileSync(logPath, parts.join(" ") + "\n");
161
+ } catch (writeErr) {
162
+ process.stderr.write(`[cerebro] configLog write failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
163
+ }
164
+ }
165
+
147
166
  export function loadPluginConfig(overrides?: Partial<OmemPluginConfig>): OmemPluginConfig {
148
167
  let config: OmemPluginConfig = structuredClone(DEFAULTS);
149
168
 
@@ -157,8 +176,8 @@ export function loadPluginConfig(overrides?: Partial<OmemPluginConfig>): OmemPlu
157
176
 
158
177
  // Merge nested groups with defaults for safety
159
178
  config = deepMerge(config, parsed);
160
- } catch {
161
- // Config file doesn't exist or is invalid, use defaults
179
+ } catch (e) {
180
+ configLog("config.json load failed, using defaults", { error: String(e) });
162
181
  }
163
182
 
164
183
  // Apply environment variable overrides (flat OMEM_* → nested paths)
@@ -201,7 +220,20 @@ export function resolveAgentPolicy(
201
220
  agentName: string,
202
221
  config: Partial<OmemPluginConfig>,
203
222
  ): AgentPolicy {
204
- return config.agentMemoryPolicy?.[agentName] ?? config.defaultPolicy ?? "readwrite";
223
+ const policies = config.agentMemoryPolicy;
224
+ if (policies) {
225
+ const exact = policies[agentName];
226
+ if (exact) return exact;
227
+ const lower = agentName.toLowerCase();
228
+ for (const [key, policy] of Object.entries(policies)) {
229
+ if (lower.startsWith(key.toLowerCase()) || key.toLowerCase().startsWith(lower)) {
230
+ return policy;
231
+ }
232
+ }
233
+ }
234
+ if (config.defaultPolicy) return config.defaultPolicy;
235
+ configLog("resolveAgentPolicy: no policy configured, defaulting to readwrite", { agentName });
236
+ return "readwrite";
205
237
  }
206
238
 
207
239
  export { DEFAULTS };
package/src/hooks.ts CHANGED
@@ -482,6 +482,15 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
482
482
  } catch {
483
483
  }
484
484
 
485
+ // Main session gate: sub-agents must not write memories via compacting
486
+ if (getMainSessionId) {
487
+ const mainId = getMainSessionId();
488
+ if (mainId && input.sessionID && input.sessionID !== mainId) {
489
+ logInfo("compactingHook: non-main session skipped", { sessionID: input.sessionID, mainSessionId: mainId });
490
+ return;
491
+ }
492
+ }
493
+
485
494
  // Policy gate: only readwrite agents can write memories
486
495
  const policy = resolveAgentPolicy(effectiveAgentId, config);
487
496
  if (policy !== "readwrite") {
@@ -515,12 +524,13 @@ export function compactingHook(client: CerebroClient, containerTags: string[], t
515
524
  }
516
525
 
517
526
  try {
518
- logInfo("compactingHook ingestMessages called", { msgCount: messages.length, sessionId: effectiveSessionId });
527
+ logInfo("compactingHook ingestMessages called", { msgCount: messages.length, sessionId: effectiveSessionId, agentId: effectiveAgentId });
519
528
  const result = await client.ingestMessages(messages, {
520
529
  mode: ingestMode,
521
530
  tags: [...containerTags, "auto-capture"],
522
531
  sessionId: effectiveSessionId,
523
532
  projectName: projectName,
533
+ agentId: effectiveAgentId,
524
534
  });
525
535
  logInfo("compactingHook ingestMessages result", { result: result === null ? "null(blocked)" : "ok" });
526
536
  if (result === null) {
@@ -553,6 +563,7 @@ export function sessionIdleHook(
553
563
  isAutoStoreEnabled?: (sessionId: string | undefined) => boolean,
554
564
  agentId?: string,
555
565
  config: Partial<OmemPluginConfig> = {},
566
+ onAgentResolved?: (name: string) => void,
556
567
  ) {
557
568
  let idleTimeout: ReturnType<typeof setTimeout> | null = null;
558
569
  let isCapturing = false;
@@ -560,6 +571,8 @@ export function sessionIdleHook(
560
571
  return async (input: { event: { type: string; properties?: any } }) => {
561
572
  if (input.event.type !== "session.idle") return;
562
573
 
574
+ logDebug("sessionIdleHook event.properties dump", { keys: Object.keys(input.event.properties || {}), raw: JSON.stringify(input.event.properties).substring(0, 2000) });
575
+
563
576
  const sessionID = input.event.properties?.sessionID;
564
577
  if (!sessionID) return;
565
578
 
@@ -567,7 +580,10 @@ export function sessionIdleHook(
567
580
 
568
581
  if (getMainSessionId) {
569
582
  const mainId = getMainSessionId();
570
- if (mainId && sessionID !== mainId) return;
583
+ if (mainId && sessionID !== mainId) {
584
+ logInfo("sessionIdleHook: non-main session skipped", { sessionID, mainSessionId: mainId });
585
+ return;
586
+ }
571
587
  }
572
588
 
573
589
  if (idleTimeout) clearTimeout(idleTimeout);
@@ -589,8 +605,6 @@ export function sessionIdleHook(
589
605
  const msgId = msg.info?.id;
590
606
  if (!msgId || processedMessageIds.has(msgId)) continue;
591
607
 
592
- // Skip messages created before this plugin instance started
593
- // (prevents replaying entire session history on restart)
594
608
  const msgTime = msg.info?.createdAt ? new Date(msg.info.createdAt).getTime() : 0;
595
609
  if (msgTime > 0 && msgTime < pluginStartTime) continue;
596
610
 
@@ -614,18 +628,15 @@ export function sessionIdleHook(
614
628
  return;
615
629
  }
616
630
 
617
- // Policy gate: only readwrite agents can write memories
618
- const policy = resolveAgentPolicy(agentId || "", config);
619
- if (policy !== "readwrite") {
620
- logInfo("sessionIdleHook blocked by policy", { agentId: agentId || "", policy });
621
- return;
622
- }
623
-
624
631
  let sessionTitle: string | undefined;
625
632
  let projectName: string | undefined;
633
+ let effectiveAgentId = agentId || "opencode";
626
634
  try {
627
635
  const sessionInfo = await sdkClient.session.get({ path: { id: sessionID } });
628
- logDebug("sessionIdleHook project.rootPath", { rootPath: sessionInfo?.data?.directory });
636
+ if ((sessionInfo?.data as any)?.agent) {
637
+ effectiveAgentId = (sessionInfo.data as any).agent;
638
+ onAgentResolved?.(effectiveAgentId);
639
+ }
629
640
  sessionTitle = sessionInfo?.data?.title;
630
641
  projectName = sessionInfo?.data?.directory
631
642
  ? await detectProjectName(sessionInfo.data.directory)
@@ -634,9 +645,17 @@ export function sessionIdleHook(
634
645
  logErr("sessionIdleHook detectProjectName failed", { error: String(e) });
635
646
  }
636
647
 
648
+ logDebug("sessionIdleHook resolved agentId", { effectiveAgentId, fallbackAgentId: agentId });
649
+
650
+ const policy = resolveAgentPolicy(effectiveAgentId, config);
651
+ if (policy !== "readwrite") {
652
+ logInfo("sessionIdleHook blocked by policy", { agentId: effectiveAgentId, policy, defaultPolicy: String(config.defaultPolicy ?? "undefined") });
653
+ return;
654
+ }
655
+
637
656
  try {
638
- logInfo("sessionIdleHook sessionIngest called", { msgCount: conversationMessages.length, sessionId: sessionID, title: String(sessionTitle) });
639
- await cerebroClient.sessionIngest(conversationMessages, sessionID, agentId, sessionTitle, projectName);
657
+ logInfo("sessionIdleHook sessionIngest called", { msgCount: conversationMessages.length, sessionId: sessionID, agentId: effectiveAgentId, title: String(sessionTitle) });
658
+ await cerebroClient.sessionIngest(conversationMessages, sessionID, effectiveAgentId, sessionTitle, projectName);
640
659
  logInfo("sessionIdleHook sessionIngest ok");
641
660
  for (const id of newMessageIds) {
642
661
  processedMessageIds.add(id);
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { CerebroClient } from "./client.js";
7
7
  import { autoRecallHook, compactingHook, keywordDetectionHook, sessionIdleHook } from "./hooks.js";
8
8
  import { getUserTag, getProjectTag } from "./tags.js";
9
9
  import { buildTools } from "./tools.js";
10
- import { logInfo, logError } from "./logger.js";
10
+ import { logInfo, logDebug, logError } from "./logger.js";
11
11
  import { loadPluginConfig } from "./config.js";
12
12
 
13
13
  const __filename = fileURLToPath(import.meta.url);
@@ -115,7 +115,9 @@ const OmemPlugin: Plugin = async (input) => {
115
115
  const containerTags = [getUserTag(email), getProjectTag(cwd)];
116
116
  const agentId = process.env.OMEM_AGENT_ID || "opencode";
117
117
 
118
- let currentSessionId: string | undefined;
118
+ let mainSessionId: string | undefined;
119
+ let mainSessionLocked = false;
120
+ let cachedAgentName: string | undefined;
119
121
 
120
122
  const recallHook = autoRecallHook(cerebroClient, containerTags, tui, config);
121
123
 
@@ -128,13 +130,18 @@ const OmemPlugin: Plugin = async (input) => {
128
130
  };
129
131
  },
130
132
  "experimental.chat.system.transform": async (input: any, output: any) => {
131
- if (input.sessionID) currentSessionId = input.sessionID;
133
+ logDebug("transform input", { sessionID: input.sessionID });
134
+ if (input.sessionID && !mainSessionLocked) {
135
+ mainSessionId = input.sessionID;
136
+ mainSessionLocked = true;
137
+ logInfo("mainSessionId locked", { sessionId: input.sessionID });
138
+ }
132
139
  return recallHook(input, output);
133
140
  },
134
141
  "chat.message": keywordDetectionHook(cerebroClient, containerTags, config.ingest.autoCaptureThreshold, tui, config.ingest.ingestMode, config, agentId),
135
- "experimental.session.compacting": compactingHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => currentSessionId, client, config, agentId),
136
- tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () => currentSessionId }),
137
- event: sessionIdleHook(cerebroClient, containerTags, tui, client, config.ingest.ingestMode, config.ingest.autoCaptureThreshold, () => currentSessionId, isAutoStoreEnabled, agentId, config),
142
+ "experimental.session.compacting": compactingHook(cerebroClient, containerTags, tui, config.ingest.ingestMode, isAutoStoreEnabled, () => mainSessionId, client, config, agentId),
143
+ tool: buildTools(cerebroClient, containerTags, { agentId, getSessionId: () => mainSessionId, getAgentName: () => cachedAgentName || agentId }),
144
+ event: sessionIdleHook(cerebroClient, containerTags, tui, client, config.ingest.ingestMode, config.ingest.autoCaptureThreshold, () => mainSessionId, isAutoStoreEnabled, agentId, config, (name: string) => { cachedAgentName = name; }),
138
145
  "shell.env": async (_input: any, output: any) => {
139
146
  if (directory) {
140
147
  output.env.OMEM_PROJECT_DIR = directory;
package/src/tools.ts CHANGED
@@ -24,6 +24,7 @@ function extractMemoryIds(result: unknown): string[] {
24
24
  export interface ToolContext {
25
25
  agentId?: string;
26
26
  getSessionId: () => string | undefined;
27
+ getAgentName?: () => string;
27
28
  }
28
29
 
29
30
  export function buildTools(client: CerebroClient, containerTags: string[], context: ToolContext) {
@@ -85,12 +86,13 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
85
86
  },
86
87
  async execute(args) {
87
88
  const allTags = [...containerTags, ...(args.tags ?? [])];
89
+ const effectiveAgentId = context.getAgentName?.() || context.agentId;
88
90
  const result = await client.createMemory(
89
91
  args.content,
90
92
  allTags,
91
93
  args.source,
92
94
  args.scope ?? "project",
93
- context.agentId,
95
+ effectiveAgentId,
94
96
  context.getSessionId(),
95
97
  args.visibility,
96
98
  args.category,
@@ -241,10 +243,12 @@ export function buildTools(client: CerebroClient, containerTags: string[], conte
241
243
  .describe("Session ID to associate with the ingestion"),
242
244
  },
243
245
  async execute(args) {
246
+ const effectiveAgentId = context.getAgentName?.() || context.agentId;
244
247
  const result = await client.ingestMessages(args.messages, {
245
248
  mode: args.mode ?? "smart",
246
249
  tags: args.tags,
247
250
  sessionId: args.session_id,
251
+ agentId: effectiveAgentId,
248
252
  });
249
253
  if (result === null) return JSON.stringify({ ok: false, error: "Ingestion failed" });
250
254
  if (args.session_id) {