@mingxy/cerebro 1.4.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.
- package/mingxy-omem-0.1.6.tgz +0 -0
- package/omem.example.jsonc +27 -0
- package/package.json +33 -0
- package/src/client.ts +359 -0
- package/src/config.ts +89 -0
- package/src/hooks.ts +379 -0
- package/src/index.ts +112 -0
- package/src/keywords.ts +23 -0
- package/src/logger.ts +46 -0
- package/src/privacy.ts +10 -0
- package/src/tags.ts +14 -0
- package/src/tools.ts +372 -0
- package/tsconfig.json +24 -0
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import type { Model, UserMessage, Part } from "@opencode-ai/sdk";
|
|
2
|
+
import type { OmemClient, SearchResult } from "./client.js";
|
|
3
|
+
import type { OmemPluginConfig } from "./config.js";
|
|
4
|
+
import { detectKeyword, KEYWORD_NUDGE } from "./keywords.js";
|
|
5
|
+
|
|
6
|
+
function showToast(tui: any, title: string, message: string, variant: string = "info", delayMs: number = 7000) {
|
|
7
|
+
if (!tui) return;
|
|
8
|
+
setTimeout(() => {
|
|
9
|
+
try {
|
|
10
|
+
tui.showToast({ body: { title, message, variant, duration: 5000 } });
|
|
11
|
+
} catch {}
|
|
12
|
+
}, delayMs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const keywordDetectedSessions = new Set<string>();
|
|
16
|
+
const injectedMemoryIds = new Map<string, Set<string>>();
|
|
17
|
+
const firstMessages = new Map<string, string>();
|
|
18
|
+
const sessionMessages = new Map<string, Array<{ role: string; content: string }>>();
|
|
19
|
+
const profileInjectedSessions = new Set<string>();
|
|
20
|
+
|
|
21
|
+
function extractMemoryIds(result: unknown): string[] {
|
|
22
|
+
if (!result) return [];
|
|
23
|
+
if (Array.isArray(result)) {
|
|
24
|
+
return (result as Array<{ id?: string }>).map((m) => m.id).filter(Boolean) as string[];
|
|
25
|
+
}
|
|
26
|
+
if (typeof result === "object" && result !== null) {
|
|
27
|
+
const r = result as Record<string, unknown>;
|
|
28
|
+
if (Array.isArray(r.memories)) {
|
|
29
|
+
return (r.memories as Array<{ id?: string }>).map((m) => m.id).filter(Boolean) as string[];
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(r.results)) {
|
|
32
|
+
return (r.results as Array<{ id?: string; memory?: { id?: string } }>)
|
|
33
|
+
.map((m) => m.id ?? m.memory?.id)
|
|
34
|
+
.filter(Boolean) as string[];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatRelativeAge(isoDate: string): string {
|
|
41
|
+
const diffMs = Date.now() - new Date(isoDate).getTime();
|
|
42
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
43
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
if (hours < 24) return `${hours}h ago`;
|
|
46
|
+
const days = Math.floor(hours / 24);
|
|
47
|
+
if (days < 30) return `${days}d ago`;
|
|
48
|
+
const months = Math.floor(days / 30);
|
|
49
|
+
return `${months}mo ago`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncate(text: string, max: number): string {
|
|
53
|
+
if (text.length <= max) return text;
|
|
54
|
+
return text.slice(0, max) + "…";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function categorize(results: SearchResult[]): Map<string, SearchResult[]> {
|
|
58
|
+
const groups = new Map<string, SearchResult[]>();
|
|
59
|
+
for (const r of results) {
|
|
60
|
+
const cat = r.memory.category || "General";
|
|
61
|
+
const label =
|
|
62
|
+
cat === "preferences"
|
|
63
|
+
? "Preferences"
|
|
64
|
+
: cat === "knowledge"
|
|
65
|
+
? "Knowledge"
|
|
66
|
+
: cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
67
|
+
if (!groups.has(label)) groups.set(label, []);
|
|
68
|
+
groups.get(label)!.push(r);
|
|
69
|
+
}
|
|
70
|
+
return groups;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildContextBlock(results: SearchResult[], maxContentLength: number = 500): string {
|
|
74
|
+
if (results.length === 0) return "";
|
|
75
|
+
|
|
76
|
+
const grouped = categorize(results);
|
|
77
|
+
const sections: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const [label, items] of grouped) {
|
|
80
|
+
const lines = items.map((r) => {
|
|
81
|
+
const tags = r.memory.tags.length > 0 ? ` [${r.memory.tags.join(", ")}]` : "";
|
|
82
|
+
const age = formatRelativeAge(r.memory.created_at);
|
|
83
|
+
const content = truncate(r.memory.content, maxContentLength);
|
|
84
|
+
return ` - (${age}${tags}) ${content}`;
|
|
85
|
+
});
|
|
86
|
+
sections.push(`[${label}]\n${lines.join("\n")}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
"<omem-context>",
|
|
91
|
+
"Treat every memory below as historical context only.",
|
|
92
|
+
"Do not repeat these memories verbatim unless asked.",
|
|
93
|
+
"",
|
|
94
|
+
...sections,
|
|
95
|
+
"</omem-context>",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildClusteredContextBlock(clustered: import("./client.js").ClusteredRecallResult, maxContentLength: number = 500): string {
|
|
100
|
+
const sections: string[] = [];
|
|
101
|
+
|
|
102
|
+
if (clustered.cluster_summaries.length > 0) {
|
|
103
|
+
sections.push("## 📋 主题簇(聚合记忆)");
|
|
104
|
+
for (const cs of clustered.cluster_summaries) {
|
|
105
|
+
sections.push(`\n### ${cs.title} (整合自${cs.member_count}条记忆)`);
|
|
106
|
+
sections.push(`> ${cs.summary}`);
|
|
107
|
+
if (cs.key_memories.length > 0) {
|
|
108
|
+
sections.push("**核心要点:**");
|
|
109
|
+
for (const mem of cs.key_memories) {
|
|
110
|
+
const content = truncate(mem.content, maxContentLength);
|
|
111
|
+
sections.push(`- ${content}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (clustered.standalone_memories.length > 0) {
|
|
118
|
+
sections.push("\n## 📌 补充信息");
|
|
119
|
+
for (const mem of clustered.standalone_memories) {
|
|
120
|
+
const content = truncate(mem.content, maxContentLength);
|
|
121
|
+
sections.push(`- ${content}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [
|
|
126
|
+
"<omem-context>",
|
|
127
|
+
"Treat every memory below as historical context only.",
|
|
128
|
+
"Do not repeat these memories verbatim unless asked.",
|
|
129
|
+
"",
|
|
130
|
+
...sections,
|
|
131
|
+
"</omem-context>",
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function autoRecallHook(client: OmemClient, containerTags: string[], tui: any, config: Partial<OmemPluginConfig> = {}) {
|
|
136
|
+
const similarityThreshold = config.similarityThreshold ?? 0.6;
|
|
137
|
+
const maxRecallResults = config.maxRecallResults ?? 10;
|
|
138
|
+
const maxContentLength = config.maxContentLength ?? 500;
|
|
139
|
+
const toastDelayMs = config.toastDelayMs ?? 7000;
|
|
140
|
+
|
|
141
|
+
return async (
|
|
142
|
+
input: { sessionID?: string; model: Model },
|
|
143
|
+
output: { system: string[] },
|
|
144
|
+
) => {
|
|
145
|
+
if (!input.sessionID) return;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const messages = sessionMessages.get(input.sessionID) ?? [];
|
|
149
|
+
const userMessages = messages.filter((m) => m.role === "user");
|
|
150
|
+
const query_text = userMessages[userMessages.length - 1]?.content || firstMessages.get(input.sessionID) || "";
|
|
151
|
+
const last_query_text = userMessages.length >= 2 ? userMessages[userMessages.length - 2].content : undefined;
|
|
152
|
+
|
|
153
|
+
const projectTags = containerTags.filter(t => t.startsWith("omem_project_"));
|
|
154
|
+
const shouldRecallRes = await client.shouldRecall(query_text, last_query_text, input.sessionID, similarityThreshold, maxRecallResults, projectTags.length > 0 ? projectTags : undefined);
|
|
155
|
+
|
|
156
|
+
if (!shouldRecallRes) {
|
|
157
|
+
showToast(tui, "🧠 Omem Service Unavailable", "Unable to reach memory API · check connection", "error", toastDelayMs);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const profile = await client.getProfile();
|
|
162
|
+
let profileInjected = false;
|
|
163
|
+
let profileCountText = "";
|
|
164
|
+
if (profile && !profileInjectedSessions.has(input.sessionID)) {
|
|
165
|
+
const profileBlock = [
|
|
166
|
+
"<omem-profile>",
|
|
167
|
+
JSON.stringify(profile, null, 2),
|
|
168
|
+
"</omem-profile>",
|
|
169
|
+
].join("\n");
|
|
170
|
+
output.system.push(profileBlock);
|
|
171
|
+
profileInjected = true;
|
|
172
|
+
profileInjectedSessions.add(input.sessionID);
|
|
173
|
+
const p = profile as any;
|
|
174
|
+
const dynamicCount = p?.dynamic_context?.length ?? 0;
|
|
175
|
+
const staticCount = p?.static_facts?.length ?? 0;
|
|
176
|
+
profileCountText = `Dynamic(${dynamicCount}) · Static(${staticCount})`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!shouldRecallRes.should_recall) {
|
|
180
|
+
if (profileInjected) {
|
|
181
|
+
showToast(tui, "👨 Profile Injected", `${profileCountText} · no memory recall needed`, "success", toastDelayMs);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const results = shouldRecallRes.memories ?? [];
|
|
187
|
+
const clustered = shouldRecallRes.clustered;
|
|
188
|
+
|
|
189
|
+
const existingIds = injectedMemoryIds.get(input.sessionID) ?? new Set<string>();
|
|
190
|
+
const newResults = results.filter((r) => !existingIds.has(r.memory.id));
|
|
191
|
+
if (newResults.length === 0) {
|
|
192
|
+
if (profileInjected) {
|
|
193
|
+
showToast(tui, "👨 Profile Injected", `${profileCountText} · all memories already injected`, "success", toastDelayMs);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const block = clustered
|
|
199
|
+
? buildClusteredContextBlock(clustered, maxContentLength)
|
|
200
|
+
: buildContextBlock(newResults, maxContentLength);
|
|
201
|
+
if (block) {
|
|
202
|
+
output.system.push(block);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const newIds = newResults.map((r) => r.memory.id);
|
|
206
|
+
injectedMemoryIds.set(input.sessionID, new Set([...existingIds, ...newIds]));
|
|
207
|
+
|
|
208
|
+
const recordResult = await client.recordSessionRecall(
|
|
209
|
+
input.sessionID,
|
|
210
|
+
newIds,
|
|
211
|
+
"auto",
|
|
212
|
+
query_text,
|
|
213
|
+
shouldRecallRes?.similarity_score,
|
|
214
|
+
shouldRecallRes?.confidence,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const memDynamic = newResults.filter((r) => r.memory.memory_type === "fact" || r.memory.memory_type === "event").length;
|
|
218
|
+
const memStatic = newResults.filter((r) => r.memory.memory_type === "pinned" || r.memory.memory_type === "preference").length;
|
|
219
|
+
const memOther = newResults.length - memDynamic - memStatic;
|
|
220
|
+
|
|
221
|
+
let memCountMsg = "";
|
|
222
|
+
if (memDynamic > 0) memCountMsg += `Dynamic(${memDynamic}) `;
|
|
223
|
+
if (memStatic > 0) memCountMsg += `Static(${memStatic}) `;
|
|
224
|
+
if (memOther > 0) memCountMsg += `Other(${memOther}) `;
|
|
225
|
+
|
|
226
|
+
const categories = categorize(newResults);
|
|
227
|
+
const catSummary = Array.from(categories.entries())
|
|
228
|
+
.map(([label, items]) => `${label}(${items.length})`)
|
|
229
|
+
.join(" · ");
|
|
230
|
+
|
|
231
|
+
let toastTitle: string;
|
|
232
|
+
let toastMessage: string;
|
|
233
|
+
|
|
234
|
+
if (clustered) {
|
|
235
|
+
const clusterCount = clustered.cluster_summaries.length;
|
|
236
|
+
const standaloneCount = clustered.standalone_memories.length;
|
|
237
|
+
toastTitle = `🧠 Context Injected · ${clusterCount} 主题簇${standaloneCount > 0 ? ` · ${standaloneCount} 补充` : ""}`;
|
|
238
|
+
toastMessage = profileInjected
|
|
239
|
+
? `Profile: ${profileCountText} · 聚合记忆展示`
|
|
240
|
+
: `聚合记忆展示`;
|
|
241
|
+
} else {
|
|
242
|
+
toastTitle = `🧠 Context Injected · ${newResults.length} fragments`;
|
|
243
|
+
toastMessage = profileInjected
|
|
244
|
+
? `Profile: ${profileCountText} · Memories: ${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`
|
|
245
|
+
: `${memCountMsg.trim()}${catSummary ? ` · ${catSummary}` : ""}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
showToast(tui, toastTitle, toastMessage, "success", toastDelayMs);
|
|
249
|
+
|
|
250
|
+
if (!recordResult) {
|
|
251
|
+
showToast(tui, "🔴 Recall Record Failed", `Memories injected but save failed · check API connection`, "warning", toastDelayMs);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (keywordDetectedSessions.has(input.sessionID)) {
|
|
255
|
+
output.system.push(KEYWORD_NUDGE);
|
|
256
|
+
keywordDetectedSessions.delete(input.sessionID);
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
260
|
+
if (errMsg.includes("[omem]")) {
|
|
261
|
+
// Server returned error (500, etc.) with details
|
|
262
|
+
const cleanMsg = errMsg.replace(/^\[omem\]\s*/, "");
|
|
263
|
+
if (cleanMsg.startsWith("500")) {
|
|
264
|
+
showToast(tui, "🧠 Omem Server Error", cleanMsg.substring(0, 200), "error");
|
|
265
|
+
} else if (cleanMsg.includes("timed out")) {
|
|
266
|
+
showToast(tui, "🧠 Omem Service Timeout", cleanMsg.substring(0, 100), "error");
|
|
267
|
+
} else {
|
|
268
|
+
showToast(tui, "🧠 Omem Error", cleanMsg.substring(0, 150), "error");
|
|
269
|
+
}
|
|
270
|
+
} else if (errMsg.includes("fetch") || errMsg.includes("network")) {
|
|
271
|
+
showToast(tui, "🧠 Omem Service Unavailable", "Network error · check API connection", "error");
|
|
272
|
+
} else {
|
|
273
|
+
showToast(tui, "🧠 Memory Recall Error", errMsg.substring(0, 100), "error");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function keywordDetectionHook(client: OmemClient, containerTags: string[], threshold: number, tui: any, ingestMode: "smart" | "raw" = "smart") {
|
|
280
|
+
return async (
|
|
281
|
+
input: { sessionID: string; messageID?: string },
|
|
282
|
+
output: { message: UserMessage; parts: Part[] },
|
|
283
|
+
) => {
|
|
284
|
+
const textContent = output.parts
|
|
285
|
+
.filter((p): p is any => p.type === "text")
|
|
286
|
+
.map((p) => (p as any).text || (p as any).content || "")
|
|
287
|
+
.join(" ")
|
|
288
|
+
|| (output.message as any).content
|
|
289
|
+
|| "";
|
|
290
|
+
|
|
291
|
+
if (!firstMessages.has(input.sessionID)) {
|
|
292
|
+
firstMessages.set(input.sessionID, textContent);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (detectKeyword(textContent)) {
|
|
296
|
+
keywordDetectedSessions.add(input.sessionID);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!sessionMessages.has(input.sessionID)) {
|
|
300
|
+
sessionMessages.set(input.sessionID, []);
|
|
301
|
+
}
|
|
302
|
+
sessionMessages.get(input.sessionID)!.push({
|
|
303
|
+
role: "user",
|
|
304
|
+
content: textContent,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const messages = sessionMessages.get(input.sessionID)!;
|
|
308
|
+
if (messages.length >= threshold) {
|
|
309
|
+
try {
|
|
310
|
+
const result = await client.ingestMessages(messages, {
|
|
311
|
+
mode: ingestMode,
|
|
312
|
+
tags: [...containerTags, "auto-capture"],
|
|
313
|
+
sessionId: input.sessionID,
|
|
314
|
+
});
|
|
315
|
+
if (result === null) {
|
|
316
|
+
showToast(tui, "🔴 Capture Failed", `Memory capture blocked · check API Key and spiritual connection`, "error");
|
|
317
|
+
} else {
|
|
318
|
+
showToast(tui, "🧠 Memory Sealed", `${messages.length} dialogues captured · entrusted to the heavens for refinement`, "success");
|
|
319
|
+
const memoryIds = extractMemoryIds(result);
|
|
320
|
+
if (memoryIds.length > 0) {
|
|
321
|
+
const recordResult = await client.recordSessionRecall(
|
|
322
|
+
input.sessionID,
|
|
323
|
+
memoryIds,
|
|
324
|
+
"auto",
|
|
325
|
+
firstMessages.get(input.sessionID) || "",
|
|
326
|
+
0,
|
|
327
|
+
0,
|
|
328
|
+
);
|
|
329
|
+
if (recordResult) {
|
|
330
|
+
showToast(tui, "📦 Capture Recorded", `${memoryIds.length} memory(s) saved to session history`, "success");
|
|
331
|
+
} else {
|
|
332
|
+
showToast(tui, "🔴 Capture Record Failed", `Failed to save capture record · check API connection`, "error");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
sessionMessages.delete(input.sessionID);
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
showToast(tui, "🔴 Capture Failed", "Memory capture blocked · spiritual pulse anomaly", "error");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function compactingHook(client: OmemClient, containerTags: string[], tui: any, ingestMode: "smart" | "raw" = "smart") {
|
|
345
|
+
return async (
|
|
346
|
+
input: { sessionID?: string },
|
|
347
|
+
output: { context: string[]; prompt?: string },
|
|
348
|
+
) => {
|
|
349
|
+
if (input.sessionID && sessionMessages.has(input.sessionID)) {
|
|
350
|
+
const messages = sessionMessages.get(input.sessionID)!;
|
|
351
|
+
if (messages.length > 0) {
|
|
352
|
+
try {
|
|
353
|
+
const result = await client.ingestMessages(messages, {
|
|
354
|
+
mode: ingestMode,
|
|
355
|
+
tags: [...containerTags, "auto-capture"],
|
|
356
|
+
sessionId: input.sessionID,
|
|
357
|
+
});
|
|
358
|
+
if (result === null) {
|
|
359
|
+
showToast(tui, "🔴 Archive Failed", "Session archive blocked · check spiritual realm status", "error");
|
|
360
|
+
} else {
|
|
361
|
+
showToast(tui, "📦 Session Archived", `${messages.length} residual dialogues archived · merged into the realm`, "success");
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
showToast(tui, "🔴 Archive Failed", "Session archive blocked · spiritual pulse anomaly", "error");
|
|
365
|
+
}
|
|
366
|
+
sessionMessages.delete(input.sessionID);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const results = await client.searchMemories("*", 20, undefined, containerTags);
|
|
372
|
+
const block = buildContextBlock(results);
|
|
373
|
+
if (block) {
|
|
374
|
+
output.context.push(block);
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { OmemClient } from "./client.js";
|
|
6
|
+
import { autoRecallHook, compactingHook, keywordDetectionHook } from "./hooks.js";
|
|
7
|
+
import { getUserTag, getProjectTag } from "./tags.js";
|
|
8
|
+
import { buildTools } from "./tools.js";
|
|
9
|
+
import { logInfo, logError } from "./logger.js";
|
|
10
|
+
import { loadPluginConfig } from "./config.js";
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
|
|
15
|
+
let pluginVersion = "unknown";
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
18
|
+
if (pkg?.version && typeof pkg.version === "string") {
|
|
19
|
+
pluginVersion = pkg.version;
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
|
|
23
|
+
function showToast(tui: any, title: string, message: string, variant: string = "info", duration: number = 5000) {
|
|
24
|
+
if (!tui) return;
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
try {
|
|
27
|
+
tui.showToast({ body: { title, message, variant, duration } });
|
|
28
|
+
} catch {}
|
|
29
|
+
}, 3000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const OmemPlugin: Plugin = async (input) => {
|
|
33
|
+
const { directory, client } = input;
|
|
34
|
+
const tui = (client as any)?.tui;
|
|
35
|
+
|
|
36
|
+
// Load overrides from opencode.json plugin_config
|
|
37
|
+
let overrides: Record<string, unknown> = {};
|
|
38
|
+
try {
|
|
39
|
+
const ocCfg = JSON.parse(readFileSync(join(directory, "opencode.json"), "utf-8"));
|
|
40
|
+
const pc = ocCfg?.plugin_config?.["@mingxy/omem"] || ocCfg?.plugin_config?.["@ourmem/opencode"];
|
|
41
|
+
if (pc) overrides = pc;
|
|
42
|
+
} catch {}
|
|
43
|
+
|
|
44
|
+
const config = loadPluginConfig(overrides as any);
|
|
45
|
+
|
|
46
|
+
const omemClient = new OmemClient(config.apiUrl, config.apiKey, config);
|
|
47
|
+
|
|
48
|
+
// 启动时检测连接状态
|
|
49
|
+
try {
|
|
50
|
+
await omemClient.getStats();
|
|
51
|
+
showToast(
|
|
52
|
+
tui,
|
|
53
|
+
`🧠 Omem v${pluginVersion} · Connected`,
|
|
54
|
+
`${config.apiUrl.replace(/^https?:\/\//, "")}`,
|
|
55
|
+
"success",
|
|
56
|
+
6000
|
|
57
|
+
);
|
|
58
|
+
logInfo(`Connected to ${config.apiUrl}`);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
61
|
+
logError(`Connection failed: ${errMsg}`);
|
|
62
|
+
if (errMsg.includes("[omem]")) {
|
|
63
|
+
const cleanMsg = errMsg.replace(/^\[omem\]\s*/, "");
|
|
64
|
+
showToast(
|
|
65
|
+
tui,
|
|
66
|
+
`🧠 Omem v${pluginVersion} · Server Error`,
|
|
67
|
+
cleanMsg.substring(0, 150),
|
|
68
|
+
"error",
|
|
69
|
+
8000
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
showToast(
|
|
73
|
+
tui,
|
|
74
|
+
`🧠 Omem v${pluginVersion} · Connection Failed`,
|
|
75
|
+
`Unable to reach ${config.apiUrl}`,
|
|
76
|
+
"error",
|
|
77
|
+
8000
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const email = process.env.GIT_AUTHOR_EMAIL || process.env.USER || "unknown";
|
|
83
|
+
const cwd = directory || process.cwd();
|
|
84
|
+
const containerTags = [getUserTag(email), getProjectTag(cwd)];
|
|
85
|
+
const agentId = process.env.OMEM_AGENT_ID || "opencode";
|
|
86
|
+
|
|
87
|
+
let currentSessionId: string | undefined;
|
|
88
|
+
|
|
89
|
+
const recallHook = autoRecallHook(omemClient, containerTags, tui, config);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"experimental.chat.system.transform": async (input: any, output: any) => {
|
|
93
|
+
if (input.sessionID) currentSessionId = input.sessionID;
|
|
94
|
+
return recallHook(input, output);
|
|
95
|
+
},
|
|
96
|
+
"chat.message": keywordDetectionHook(omemClient, containerTags, config.autoCaptureThreshold, tui, config.ingestMode),
|
|
97
|
+
"experimental.session.compacting": compactingHook(omemClient, containerTags, tui, config.ingestMode),
|
|
98
|
+
tool: buildTools(omemClient, containerTags, { agentId, getSessionId: () => currentSessionId }),
|
|
99
|
+
"shell.env": async (_input: any, output: any) => {
|
|
100
|
+
if (directory) {
|
|
101
|
+
output.env.OMEM_PROJECT_DIR = directory;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export { OmemPlugin };
|
|
108
|
+
|
|
109
|
+
export default {
|
|
110
|
+
id: "ourmem",
|
|
111
|
+
server: OmemPlugin,
|
|
112
|
+
};
|
package/src/keywords.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const KEYWORDS: readonly string[] = [
|
|
2
|
+
"remember",
|
|
3
|
+
"save this",
|
|
4
|
+
"don't forget",
|
|
5
|
+
"keep in mind",
|
|
6
|
+
"note that",
|
|
7
|
+
"store this",
|
|
8
|
+
"memorize",
|
|
9
|
+
"记住",
|
|
10
|
+
"记一下",
|
|
11
|
+
"保存",
|
|
12
|
+
"记下来",
|
|
13
|
+
"别忘了",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export function detectKeyword(text: string): boolean {
|
|
17
|
+
const lower = text.toLowerCase();
|
|
18
|
+
return KEYWORDS.some((kw) => lower.includes(kw));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const KEYWORD_NUDGE =
|
|
22
|
+
"The user appears to want you to remember something. " +
|
|
23
|
+
"Consider using the `memory_store` tool to save this information for future reference.";
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const LOG_DIR = join(homedir(), ".config", "ourmem");
|
|
6
|
+
const LOG_FILE = join(LOG_DIR, "plugin.log");
|
|
7
|
+
|
|
8
|
+
function ensureLogDir(): void {
|
|
9
|
+
if (!existsSync(LOG_DIR)) {
|
|
10
|
+
try {
|
|
11
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
12
|
+
} catch {
|
|
13
|
+
// silently fail if we can't create log directory
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeLog(level: string, ...args: unknown[]): void {
|
|
19
|
+
ensureLogDir();
|
|
20
|
+
const timestamp = new Date().toISOString();
|
|
21
|
+
const message = args
|
|
22
|
+
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
23
|
+
.join(" ");
|
|
24
|
+
const line = `[${timestamp}] [${level}] ${message}\n`;
|
|
25
|
+
try {
|
|
26
|
+
appendFileSync(LOG_FILE, line);
|
|
27
|
+
} catch {
|
|
28
|
+
// silently fail if we can't write to log file
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function logInfo(...args: unknown[]): void {
|
|
33
|
+
writeLog("INFO", ...args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function logWarn(...args: unknown[]): void {
|
|
37
|
+
writeLog("WARN", ...args);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function logError(...args: unknown[]): void {
|
|
41
|
+
writeLog("ERROR", ...args);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function logDebug(...args: unknown[]): void {
|
|
45
|
+
writeLog("DEBUG", ...args);
|
|
46
|
+
}
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function stripPrivateContent(text: string): string {
|
|
2
|
+
return text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isFullyPrivate(text: string): boolean {
|
|
6
|
+
const stripped = stripPrivateContent(text)
|
|
7
|
+
.replace(/\[REDACTED\]/g, "")
|
|
8
|
+
.trim();
|
|
9
|
+
return stripped.length === 0;
|
|
10
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
export function getUserTag(email: string): string {
|
|
4
|
+
const hash = createHash("sha256").update(email).digest("hex").slice(0, 16);
|
|
5
|
+
return `omem_user_${hash}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getProjectTag(directory: string): string {
|
|
9
|
+
const hash = createHash("sha256")
|
|
10
|
+
.update(directory)
|
|
11
|
+
.digest("hex")
|
|
12
|
+
.slice(0, 16);
|
|
13
|
+
return `omem_project_${hash}`;
|
|
14
|
+
}
|