@mingxy/cerebro-claude-code 0.3.3

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.
@@ -0,0 +1,687 @@
1
+ // cerebro Claude Code plugin — shared base (Node cross-platform)
2
+ // Ported from common.sh — no bash/curl/python3 dependency. Pure Node.
3
+ // Config cascade: env > ~/.config/cerebro/config.json > builtin defaults
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, copyFileSync } from "node:fs";
6
+ import { join, dirname, resolve } from "node:path";
7
+ import { execSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const PLUGIN_ROOT =
11
+ process.env.CLAUDE_PLUGIN_ROOT ||
12
+ resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
+
14
+ // Read version from package.json (single source of truth, no hardcoding)
15
+ let _pkgVersion = "unknown";
16
+ try {
17
+ _pkgVersion = JSON.parse(readFileSync(join(PLUGIN_ROOT, "package.json"), "utf-8")).version || "unknown";
18
+ } catch {}
19
+ export const PLUGIN_VERSION = _pkgVersion;
20
+
21
+ const HOME = process.env.HOME || process.env.USERPROFILE || process.cwd();
22
+
23
+ // ─── Builtin defaults ────────────────────────────────────────────────────────
24
+ const DEF = {
25
+ apiUrl: "https://www.mengxy.cc",
26
+ requestTimeout: 15,
27
+ recentCount: 8,
28
+ searchCount: 8,
29
+ maxContent: 3000,
30
+ maxQueryLength: 200,
31
+ logDir: join(HOME, ".config/cerebro/logs"),
32
+ logEnabled: true,
33
+ profileTimeoutMs: 2000,
34
+ recentTimeoutMs: 6000,
35
+ };
36
+
37
+ // ─── Config cascade ──────────────────────────────────────────────────────────
38
+ function loadConfig() {
39
+ let cfg = {};
40
+ const cfgPath =
41
+ process.env.CEREBRO_CONFIG_PATH || join(HOME, ".config/cerebro/config.json");
42
+ try {
43
+ if (existsSync(cfgPath)) {
44
+ const raw = JSON.parse(readFileSync(cfgPath, "utf-8"));
45
+ // Flat-config migration (legacy)
46
+ if (raw.apiUrl && !raw.connection) {
47
+ cfg = {
48
+ connection: { apiUrl: raw.apiUrl, apiKey: raw.apiKey, requestTimeoutMs: raw.requestTimeoutMs },
49
+ content: { maxQueryLength: raw.maxQueryLength, maxContentChars: raw.maxContentChars, maxContentLength: raw.maxContentLength },
50
+ injection: { recentCount: raw.recentCount, searchCount: raw.searchCount },
51
+ ingest: { autoCaptureThreshold: raw.autoCaptureThreshold, ingestMode: raw.ingestMode },
52
+ logging: { logEnabled: raw.logEnabled, logLevel: raw.logLevel, logDir: raw.logDir },
53
+ };
54
+ } else {
55
+ cfg = raw;
56
+ }
57
+ }
58
+ } catch {}
59
+
60
+ const c = cfg.connection || {};
61
+ const i = cfg.injection || {};
62
+ const ct = cfg.content || {};
63
+ const lg = cfg.logging || {};
64
+
65
+ const num = (env, cfgVal, def) => {
66
+ const v = process.env[env];
67
+ if (v && /^\d+$/.test(v)) return parseInt(v, 10);
68
+ if (cfgVal) return typeof cfgVal === "number" ? cfgVal : parseInt(cfgVal, 10);
69
+ return def;
70
+ };
71
+
72
+ return {
73
+ apiUrl: (process.env.OMEM_API_URL || c.apiUrl || DEF.apiUrl).replace(/\/$/, ""),
74
+ apiKey: process.env.OMEM_API_KEY || c.apiKey || "",
75
+ requestTimeout: num("MEM_REQUEST_TIMEOUT", c.requestTimeoutMs ? c.requestTimeoutMs / 1000 : null, DEF.requestTimeout),
76
+ recentCount: num("MEM_RECENT_COUNT", i.recentCount, DEF.recentCount),
77
+ searchCount: num("MEM_SEARCH_COUNT", i.searchCount, DEF.searchCount),
78
+ maxContent: num("MEM_MAX_CONTENT", ct.maxContentLength || ct.maxContentChars, DEF.maxContent),
79
+ maxQueryLength: num("MEM_MAX_QUERY_LENGTH", ct.maxQueryLength, DEF.maxQueryLength),
80
+ logDir: (process.env.MEM_LOG_DIR || lg.logDir || DEF.logDir).replace(/^~/, HOME),
81
+ logEnabled: process.env.MEM_LOG_ENABLED
82
+ ? process.env.MEM_LOG_ENABLED === "1"
83
+ : lg.logEnabled !== undefined
84
+ ? lg.logEnabled === true || lg.logEnabled === "1" || lg.logEnabled === 1
85
+ : DEF.logEnabled,
86
+ profileTimeoutMs: i.profileTimeoutMs || DEF.profileTimeoutMs,
87
+ recentTimeoutMs: i.recentTimeoutMs || DEF.recentTimeoutMs,
88
+ };
89
+ }
90
+
91
+ export const config = loadConfig();
92
+
93
+ // ─── Injection config (Claude Code specific, independent from opencode) ──────
94
+ // Three-level fallback: ~/.claude/cerebro.json > auto-init from bundled > bundled default
95
+ const CC_CONFIG_DIR = join(HOME, ".claude");
96
+ const CC_USER_CONFIG = join(CC_CONFIG_DIR, "cerebro.json");
97
+ const CC_BUNDLED_CONFIG = join(PLUGIN_ROOT, "config.json");
98
+
99
+ function loadInjectionConfig() {
100
+ // 1. User config exists → read it
101
+ if (existsSync(CC_USER_CONFIG)) {
102
+ try {
103
+ return JSON.parse(readFileSync(CC_USER_CONFIG, "utf-8"));
104
+ } catch {}
105
+ } else {
106
+ // 2. First run → auto-initialize by copying bundled default
107
+ try {
108
+ mkdirSync(CC_CONFIG_DIR, { recursive: true });
109
+ copyFileSync(CC_BUNDLED_CONFIG, CC_USER_CONFIG);
110
+ } catch {}
111
+ }
112
+ // 3. Fallback → read bundled default
113
+ try {
114
+ return JSON.parse(readFileSync(CC_BUNDLED_CONFIG, "utf-8"));
115
+ } catch {
116
+ return {
117
+ language: "en",
118
+ recall: { enabled: true },
119
+ nudge: { enabled: true },
120
+ sessionStart: { profileEnabled: true, recentActivityEnabled: true },
121
+ };
122
+ }
123
+ }
124
+
125
+ export const injectionConfig = loadInjectionConfig();
126
+
127
+ // ─── HTTP (fetch-based, cross-platform) ──────────────────────────────────────
128
+ function headers(extra = {}) {
129
+ return { "X-API-Key": config.apiKey, Accept: "application/json", ...extra };
130
+ }
131
+
132
+ export async function omGet(path, timeout = 8) {
133
+ try {
134
+ const resp = await fetch(`${config.apiUrl}${path}`, {
135
+ headers: headers(),
136
+ signal: AbortSignal.timeout(timeout * 1000),
137
+ });
138
+ return await resp.text();
139
+ } catch {
140
+ return '{"error":"request failed"}';
141
+ }
142
+ }
143
+
144
+ export async function omPost(path, body, timeout) {
145
+ timeout = timeout || config.requestTimeout;
146
+ try {
147
+ const resp = await fetch(`${config.apiUrl}${path}`, {
148
+ method: "POST",
149
+ headers: headers({ "Content-Type": "application/json" }),
150
+ body: typeof body === "string" ? body : JSON.stringify(body),
151
+ signal: AbortSignal.timeout(timeout * 1000),
152
+ });
153
+ return { ok: resp.ok, status: resp.status, text: await resp.text() };
154
+ } catch {
155
+ return { ok: false, status: 0, text: "" };
156
+ }
157
+ }
158
+
159
+ export async function omHealth() {
160
+ try {
161
+ const resp = await fetch(`${config.apiUrl}/v1/stats`, {
162
+ headers: headers(),
163
+ signal: AbortSignal.timeout(5000),
164
+ });
165
+ return resp.ok;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ // ─── Project / User Tagging ──────────────────────────────────────────────────
172
+ function sha256_16(input) {
173
+ return createHash("sha256").update(input).digest("hex").slice(0, 16);
174
+ }
175
+
176
+ export function detectProjectPath() {
177
+ try {
178
+ const p = execSync("git rev-parse --show-toplevel", {
179
+ encoding: "utf-8",
180
+ timeout: 2000,
181
+ stdio: ["pipe", "pipe", "pipe"],
182
+ }).trim();
183
+ if (p && p !== "/" && p !== HOME) return p;
184
+ } catch {}
185
+ return process.cwd();
186
+ }
187
+
188
+ export function containerTags() {
189
+ let email = process.env.OMEM_USER_EMAIL;
190
+ if (!email) {
191
+ try {
192
+ email = execSync("git config user.email", {
193
+ encoding: "utf-8",
194
+ timeout: 2000,
195
+ stdio: ["pipe", "pipe", "pipe"],
196
+ }).trim();
197
+ } catch {}
198
+ }
199
+ const projectDir = detectProjectPath();
200
+ const tags = [];
201
+ if (email) tags.push(`omem_user_${sha256_16(email)}`);
202
+ if (projectDir && projectDir !== HOME) tags.push(`omem_project_${sha256_16(projectDir)}`);
203
+ return tags;
204
+ }
205
+
206
+ // ─── Logging ─────────────────────────────────────────────────────────────────
207
+ export function logWarn(msg) { _log("WARN", msg); }
208
+ export function logError(msg) { _log("ERROR", msg); }
209
+ export function logDebug(msg) { _log("DEBUG", msg); }
210
+
211
+ function _log(level, msg) {
212
+ if (!config.logEnabled) return;
213
+ try {
214
+ const ts = new Date().toISOString();
215
+ const logFile = join(config.logDir, "claude-code.log");
216
+ mkdirSync(config.logDir, { recursive: true });
217
+ appendFileSync(logFile, `${ts} ${level} ${msg}\n`);
218
+ } catch {}
219
+ }
220
+
221
+ // ─── Web server refcount (multi-session lifecycle) ───────────────────────────
222
+ const REFCOUNT_FILE = join(HOME, ".config/cerebro/web-server.refcount");
223
+ const WEB_PID_FILE = join(HOME, ".config/cerebro/web-server.pid");
224
+
225
+ export function refCountInc() {
226
+ try {
227
+ const n = existsSync(REFCOUNT_FILE) ? parseInt(readFileSync(REFCOUNT_FILE, "utf-8").trim(), 10) || 0 : 0;
228
+ writeFileSync(REFCOUNT_FILE, String(n + 1));
229
+ } catch {}
230
+ }
231
+
232
+ export function refCountDec() {
233
+ try {
234
+ let n = existsSync(REFCOUNT_FILE) ? parseInt(readFileSync(REFCOUNT_FILE, "utf-8").trim(), 10) || 0 : 0;
235
+ n = Math.max(0, n - 1);
236
+ if (n === 0) {
237
+ try {
238
+ const pid = parseInt(readFileSync(WEB_PID_FILE, "utf-8").trim(), 10);
239
+ if (pid) process.kill(pid, "SIGTERM");
240
+ } catch {}
241
+ try { unlinkSync(WEB_PID_FILE); } catch {}
242
+ try { unlinkSync(REFCOUNT_FILE); } catch {}
243
+ } else {
244
+ writeFileSync(REFCOUNT_FILE, String(n));
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ // ─── Compact result handoff (PostCompact → SessionStart:compact) ─────────────
250
+ const COMPACT_RESULT_FILE = join(HOME, ".config/cerebro/last-compact-result.json");
251
+
252
+ export function writeCompactResult(result) {
253
+ try {
254
+ mkdirSync(dirname(COMPACT_RESULT_FILE), { recursive: true });
255
+ writeFileSync(COMPACT_RESULT_FILE, JSON.stringify({ ...result, ts: Date.now() }));
256
+ } catch {}
257
+ }
258
+
259
+ export function readCompactResult() {
260
+ try {
261
+ if (!existsSync(COMPACT_RESULT_FILE)) return null;
262
+ const data = JSON.parse(readFileSync(COMPACT_RESULT_FILE, "utf-8"));
263
+ // Stale after 60s
264
+ if (Date.now() - (data.ts || 0) > 60_000) return null;
265
+ return data;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ // ─── Cursor (session ingest dedup) ───────────────────────────────────────────
272
+ const TRACKER_DIR = join(HOME, ".config/cerebro/trackers");
273
+
274
+ // ─── Stop counter (controls flush frequency in Stop hook) ────────────────────
275
+ // Per-session file to avoid multi-session race condition
276
+
277
+ export function stopCounterGet(sessionId) {
278
+ const f = join(TRACKER_DIR, `stop-counter-${sessionId}.json`);
279
+ try {
280
+ if (!existsSync(f)) return 0;
281
+ const data = JSON.parse(readFileSync(f, "utf-8"));
282
+ return data.count || 0;
283
+ } catch {
284
+ return 0;
285
+ }
286
+ }
287
+
288
+ export function stopCounterSet(sessionId, count) {
289
+ const f = join(TRACKER_DIR, `stop-counter-${sessionId}.json`);
290
+ try {
291
+ mkdirSync(TRACKER_DIR, { recursive: true });
292
+ writeFileSync(f, JSON.stringify({ count }));
293
+ } catch {}
294
+ }
295
+
296
+ export function cursorGet(sessionId) {
297
+ try {
298
+ const f = join(TRACKER_DIR, `${sessionId}.txt`);
299
+ if (existsSync(f)) return readFileSync(f, "utf-8").trim();
300
+ } catch {}
301
+ return "";
302
+ }
303
+
304
+ export function cursorSet(sessionId, lastId) {
305
+ try {
306
+ mkdirSync(TRACKER_DIR, { recursive: true });
307
+ writeFileSync(join(TRACKER_DIR, `${sessionId}.txt`), lastId + "\n");
308
+ } catch {}
309
+ }
310
+
311
+ // ─── Project name detection ──────────────────────────────────────────────────
312
+ export function detectProjectName() {
313
+ const dir = detectProjectPath();
314
+ const manifests = [
315
+ ["package.json", "json"],
316
+ ["composer.json", "json"],
317
+ ["Cargo.toml", "toml"],
318
+ ["pyproject.toml", "toml"],
319
+ ["go.mod", "go"],
320
+ ];
321
+ for (const [mf, kind] of manifests) {
322
+ const p = join(dir, mf);
323
+ if (!existsSync(p)) continue;
324
+ try {
325
+ const txt = readFileSync(p, "utf-8");
326
+ if (kind === "json") {
327
+ const v = JSON.parse(txt).name;
328
+ if (typeof v === "string" && v) return v.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
329
+ } else if (kind === "toml") {
330
+ const m = txt.match(/^name\s*=\s*"([^"]+)"/m);
331
+ if (m) return m[1].replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
332
+ } else if (kind === "go") {
333
+ const m = txt.match(/^module\s+(\S+)/m);
334
+ if (m) {
335
+ const name = m[1].replace(/\/+$/, "").split("/").pop();
336
+ return name.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
337
+ }
338
+ }
339
+ } catch {}
340
+ }
341
+ const basename = dir.replace(/\/+$/, "").split("/").pop() || "project";
342
+ return basename.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32) || "project";
343
+ }
344
+
345
+ // ─── Hook helpers ────────────────────────────────────────────────────────────
346
+ export function readStdin() {
347
+ try {
348
+ return readFileSync(0, "utf-8");
349
+ } catch {
350
+ return "";
351
+ }
352
+ }
353
+
354
+ export function parseStdinJSON() {
355
+ try {
356
+ const raw = readStdin();
357
+ const parsed = raw ? JSON.parse(raw) : {};
358
+ // CC hook 可能以 CLAUDE_PLUGIN_ROOT(缓存目录)为 cwd 运行,
359
+ // 导致 detectProjectPath() 返回错误路径。
360
+ // CC stdin 提供 cwd 字段(用户项目目录),chdir 到正确位置。
361
+ if (parsed.cwd && parsed.cwd !== process.cwd()) {
362
+ try { process.chdir(parsed.cwd); } catch {}
363
+ }
364
+ return parsed;
365
+ } catch {
366
+ return {};
367
+ }
368
+ }
369
+
370
+ export function emit(obj) {
371
+ process.stdout.write(JSON.stringify(obj));
372
+ }
373
+
374
+ // ─── Session ingest flush (shared by Stop + PreCompact) ──────────────────────
375
+ // Walks transcript JSONL past the saved cursor uuid, filters entries
376
+ // (strip inject-echo blocks, drop thinking, truncate tool_result/tool_use),
377
+ // POSTs the delta to /v1/memories/session-ingest, advances cursor on 2xx.
378
+ const INJECT_TAG_RE = /<(system-reminder|cerebro-[a-z0-9_-]+|supermemory-[a-z0-9_-]+)\b[^>]*>[\s\S]*?<\/\1>/gi;
379
+ const INJECT_SELF_RE = /<(system-reminder|cerebro-[a-z0-9_-]+|supermemory-[a-z0-9_-]+)\b[^>]*\/>/gi;
380
+ const WS_RE = /\s+/g;
381
+
382
+ export function cleanText(s) {
383
+ if (typeof s !== "string") s = String(s);
384
+ return s.replace(INJECT_TAG_RE, "").replace(INJECT_SELF_RE, "").replace(WS_RE, " ").trim();
385
+ }
386
+
387
+ function blockText(b) {
388
+ const t = b.type;
389
+ if (t === "text") return b.text || "";
390
+ if (t === "thinking") return null;
391
+ if (t === "tool_result") {
392
+ let c = b.content || "";
393
+ if (Array.isArray(c)) {
394
+ c = c
395
+ .map((x) => (typeof x === "object" && x?.type === "text" ? x.text || "" : typeof x === "string" ? x : ""))
396
+ .join("\n");
397
+ } else if (typeof c !== "string") {
398
+ try { c = JSON.stringify(c); } catch { c = String(c); }
399
+ }
400
+ return "tool_result: " + (c || "").slice(0, 500);
401
+ }
402
+ if (t === "tool_use") {
403
+ let inp;
404
+ try { inp = JSON.stringify(b.input); } catch { inp = String(b.input); }
405
+ return `tool_use(${b.name || "?"}): ${inp.slice(0, 100)}`;
406
+ }
407
+ return null;
408
+ }
409
+
410
+ function contentText(content) {
411
+ if (typeof content === "string") return content;
412
+ if (Array.isArray(content)) {
413
+ return content
414
+ .map((b) => (typeof b === "object" && b !== null ? blockText(b) : null))
415
+ .filter(Boolean)
416
+ .join("\n");
417
+ }
418
+ return "";
419
+ }
420
+
421
+ export async function flushSessionIngest(transcriptPath, sessionId) {
422
+ if (!transcriptPath || !existsSync(transcriptPath) || !sessionId || !config.apiKey) return { ok: false, count: 0 };
423
+
424
+ const cursor = cursorGet(sessionId);
425
+ const pn = detectProjectName();
426
+ const pp = detectProjectPath();
427
+
428
+ let entries = [];
429
+ try {
430
+ const lines = readFileSync(transcriptPath, "utf-8").split("\n");
431
+ for (const line of lines) {
432
+ const trimmed = line.trim();
433
+ if (!trimmed) continue;
434
+ let d;
435
+ try { d = JSON.parse(trimmed); } catch { continue; }
436
+ if (d.type !== "user" && d.type !== "assistant") continue;
437
+ const uid = d.uuid || "";
438
+ const msg = d.message;
439
+ if (typeof msg !== "object" || msg === null) continue;
440
+ const role = msg.role;
441
+ if (role !== "user" && role !== "assistant") continue;
442
+ const raw = contentText(msg.content);
443
+ entries.push({ uid, role, raw });
444
+ }
445
+ } catch {
446
+ entries = [];
447
+ }
448
+
449
+ // Locate cursor
450
+ let start = 0;
451
+ if (cursor) {
452
+ for (let idx = 0; idx < entries.length; idx++) {
453
+ if (entries[idx].uid === cursor) {
454
+ start = idx + 1;
455
+ break;
456
+ }
457
+ }
458
+ }
459
+
460
+ const delta = entries.slice(start);
461
+ if (delta.length === 0) return { ok: true, count: 0 }; // nothing new
462
+
463
+ const lastUid = delta[delta.length - 1].uid;
464
+ const messages = [];
465
+ for (const { role, raw } of delta) {
466
+ const txt = cleanText(raw);
467
+ if (txt.length < 100) continue;
468
+ messages.push({ role, content: txt });
469
+ }
470
+
471
+ const agentId = process.env.OMEM_AGENT_ID || "claude-code";
472
+
473
+ if (messages.length > 0) {
474
+ const body = { messages, agent_id: agentId };
475
+ if (sessionId) body.session_id = sessionId;
476
+ if (pn) body.project_name = pn;
477
+ if (pp) body.project_path = pp;
478
+
479
+ const result = await omPost("/v1/memories/session-ingest", body, 25);
480
+ if (result.status >= 200 && result.status < 300) {
481
+ cursorSet(sessionId, lastUid);
482
+ logDebug(`flush_session_ingest: ok http=${result.status} cursor=${lastUid}`);
483
+ return { ok: true, count: messages.length };
484
+ } else {
485
+ logError(`flush_session_ingest: http=${result.status} (cursor NOT advanced, will retry next run)`);
486
+ return { ok: false, count: 0 };
487
+ }
488
+ } else {
489
+ // All fragments — advance cursor anyway
490
+ cursorSet(sessionId, lastUid);
491
+ logDebug(`flush_session_ingest: 0 msgs kept (fragments), advancing cursor=${lastUid}`);
492
+ return { ok: true, count: 0 };
493
+ }
494
+ }
495
+
496
+ // ─── Sanitize / Truncate ─────────────────────────────────────────────────────
497
+ export function sanitizeContent(text, maxLen) {
498
+ maxLen = maxLen || config.maxContent;
499
+ let clean = text
500
+ .replace(/<[\w-]+[^>]*>[\s\S]*?<\/[\w-]+>/g, "")
501
+ .replace(/<[\w-]+[^>]*\/>/g, "")
502
+ .replace(/\s+/g, " ")
503
+ .trim();
504
+ return clean.length <= maxLen ? clean : clean.slice(0, maxLen) + "…[truncated]";
505
+ }
506
+
507
+ export function truncateQuery(text, len) {
508
+ len = len || config.maxQueryLength;
509
+ if (!text) return "";
510
+ return text.length <= len ? text : text.slice(0, len);
511
+ }
512
+
513
+ // ─── Injection helpers (对标 opencode hooks.ts) ──────────────────────────────
514
+
515
+ const BOUNDARY_SEARCH_RATIO = 0.6;
516
+ const MAX_INJECTION_CHARS = 10000; // CC additionalContext 单字段上限
517
+
518
+ export function formatRelativeAge(isoDate) {
519
+ if (!isoDate) return "unknown";
520
+ const diffMs = Date.now() - new Date(isoDate).getTime();
521
+ if (isNaN(diffMs)) return "unknown";
522
+ const minutes = Math.floor(diffMs / 60000);
523
+ if (minutes < 60) return `${minutes}m ago`;
524
+ const hours = Math.floor(minutes / 60);
525
+ if (hours < 24) return `${hours}h ago`;
526
+ const days = Math.floor(hours / 24);
527
+ if (days < 30) return `${days}d ago`;
528
+ return `${Math.floor(days / 30)}mo ago`;
529
+ }
530
+
531
+ export function truncateAtBoundary(text, maxLength) {
532
+ if (text.length <= maxLength) return text;
533
+ const boundaries = /[.!?。!?\n]/;
534
+ const searchEnd = Math.min(maxLength, text.length);
535
+ for (let i = searchEnd - 1; i >= Math.floor(searchEnd * BOUNDARY_SEARCH_RATIO); i--) {
536
+ if (boundaries.test(text[i])) return text.slice(0, i + 1).trimEnd() + "…";
537
+ }
538
+ let truncated = text.slice(0, maxLength);
539
+ const lastCode = truncated.charCodeAt(truncated.length - 1);
540
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff) truncated = truncated.slice(0, -1);
541
+ return truncated + "…";
542
+ }
543
+
544
+ // GET /v1/memories/search — 单路语义搜索
545
+ export async function searchMemories(query, limit, projectPath) {
546
+ limit = limit || config.searchCount;
547
+ const safeQ = truncateQuery(query);
548
+ if (!safeQ) return [];
549
+ const params = new URLSearchParams({ q: safeQ, limit: String(limit) });
550
+ if (projectPath) params.set("project_path", projectPath);
551
+ try {
552
+ const resp = await fetch(`${config.apiUrl}/v1/memories/search?${params}`, {
553
+ headers: { "X-API-Key": config.apiKey, Accept: "application/json" },
554
+ signal: AbortSignal.timeout(5000),
555
+ });
556
+ const d = await resp.json();
557
+ return d?.results || [];
558
+ } catch {
559
+ return [];
560
+ }
561
+ }
562
+
563
+ // buildMemoryInjection — 对标 opencode hooks.ts:246-329
564
+ // 三路并发:profile + recent + search(query)。query 为空跳过 search。
565
+ export async function buildMemoryInjection(query, projectPath, options = {}) {
566
+ const profileEnabled = options.profileEnabled !== false;
567
+ const recentEnabled = options.recentEnabled !== false;
568
+ const hdrs = { "X-API-Key": config.apiKey, Accept: "application/json" };
569
+ const recentCount = config.recentCount;
570
+ const searchCount = config.searchCount;
571
+ const profileQs = projectPath ? `?project_path=${encodeURIComponent(projectPath)}` : "";
572
+ const recentQs = `?limit=${recentCount}&offset=0&sort=updated_at&order=desc${projectPath ? `&project_path=${encodeURIComponent(projectPath)}` : ""}`;
573
+ const safeQ = truncateQuery(query);
574
+
575
+ const [profileResp, recentResp, searchResp] = await Promise.all([
576
+ profileEnabled
577
+ ? fetch(`${config.apiUrl}/v2/profile/inject${profileQs}`, { headers: hdrs, signal: AbortSignal.timeout(config.profileTimeoutMs) })
578
+ .then((r) => r.text()).catch(() => "")
579
+ : Promise.resolve(""),
580
+ recentEnabled
581
+ ? fetch(`${config.apiUrl}/v1/memories${recentQs}`, { headers: hdrs, signal: AbortSignal.timeout(config.recentTimeoutMs) })
582
+ .then((r) => (r.ok ? r.text() : null)).catch(() => null)
583
+ : Promise.resolve(""),
584
+ safeQ
585
+ ? fetch(`${config.apiUrl}/v1/memories/search?q=${encodeURIComponent(safeQ)}&limit=${searchCount}${projectPath ? `&project_path=${encodeURIComponent(projectPath)}` : ""}`, { headers: hdrs, signal: AbortSignal.timeout(5000) })
586
+ .then((r) => r.text()).catch(() => "")
587
+ : Promise.resolve(""),
588
+ ]);
589
+
590
+ // parse profile
591
+ let profileContent = "";
592
+ try {
593
+ const pd = JSON.parse(profileResp);
594
+ if (pd && !pd.error) profileContent = (pd.content || "").trim();
595
+ } catch {}
596
+
597
+ // parse recent (用完整 content,不用 l0_abstract)
598
+ let projectMemories = [];
599
+ let recentFailed = false;
600
+ if (recentResp === null) {
601
+ recentFailed = true;
602
+ } else {
603
+ try {
604
+ const rd = JSON.parse(recentResp);
605
+ if (rd && !rd.error) projectMemories = rd.memories || [];
606
+ } catch {}
607
+ }
608
+
609
+ // parse search
610
+ let searchResults = [];
611
+ try {
612
+ const sd = JSON.parse(searchResp);
613
+ if (sd && !sd.error) searchResults = sd.results || [];
614
+ } catch {}
615
+
616
+ // build [CEREBRO-MEMORY] block
617
+ const sections = ["[CEREBRO-MEMORY]", ""];
618
+
619
+ if (profileContent) {
620
+ sections.push(profileContent);
621
+ sections.push("");
622
+ }
623
+
624
+ const seenIds = new Set();
625
+ if (projectMemories.length > 0) {
626
+ sections.push("## Recent Project Activity");
627
+ for (const m of projectMemories) {
628
+ if (m.id) seenIds.add(m.id);
629
+ const age = formatRelativeAge(m.updated_at || m.created_at);
630
+ sections.push(`- (${age}) ${m.content || ""}`);
631
+ }
632
+ sections.push("");
633
+ }
634
+
635
+ const dedupedResults = searchResults.filter((r) => r.memory && !seenIds.has(r.memory.id));
636
+ if (dedupedResults.length > 0) {
637
+ sections.push("## Relevant Memories");
638
+ for (const r of dedupedResults) {
639
+ const age = formatRelativeAge(r.memory.created_at);
640
+ sections.push(`- (${age}) ${r.memory.content || ""}`);
641
+ }
642
+ sections.push("");
643
+ }
644
+
645
+ sections.push("[/CEREBRO-MEMORY]");
646
+
647
+ let text = sections.join("\n");
648
+ // maxChars 截断保护
649
+ if (text.length > MAX_INJECTION_CHARS) {
650
+ const cutoff = text.lastIndexOf("\n", MAX_INJECTION_CHARS);
651
+ text = text.slice(0, cutoff > 0 ? cutoff : MAX_INJECTION_CHARS) + "\n…\n[/CEREBRO-MEMORY]";
652
+ }
653
+
654
+ return {
655
+ text,
656
+ profileCount: profileContent ? 1 : 0,
657
+ projectMemoryCount: projectMemories.length,
658
+ searchCount: dedupedResults.length,
659
+ recentFailed,
660
+ };
661
+ }
662
+
663
+ // ─── POST recall-event (shared by session-start + user-prompt-submit) ─────────
664
+ // 让 web Sessions 页面看到每次注入的内容。对标 opencode chatMessageRecallHook createRecallEvent。
665
+ export async function postRecallEvent({ sessionId, recallType, queryText, profileInjected, keptCount, injectedContent, maxScore = 0, failureReason = "" }) {
666
+ if (!sessionId || !config.apiKey) return;
667
+ try {
668
+ await fetch(`${config.apiUrl}/v1/recall-events`, {
669
+ method: "POST",
670
+ headers: { "X-API-Key": config.apiKey, "Content-Type": "application/json" },
671
+ body: JSON.stringify({
672
+ session_id: sessionId,
673
+ recall_type: recallType,
674
+ query_text: (queryText || "").slice(0, 500),
675
+ max_score: maxScore,
676
+ llm_confidence: 0,
677
+ profile_injected: profileInjected || false,
678
+ kept_count: keptCount || 0,
679
+ discarded_count: 0,
680
+ injected_count: keptCount || 0,
681
+ injected_content: (injectedContent || "").slice(0, 10000),
682
+ failure_reason: (failureReason || "").slice(0, 200),
683
+ }),
684
+ signal: AbortSignal.timeout(5000),
685
+ });
686
+ } catch {}
687
+ }