@simbimbo/memory-ocmemog 0.1.4
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/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/brain/__init__.py +1 -0
- package/brain/runtime/__init__.py +13 -0
- package/brain/runtime/config.py +21 -0
- package/brain/runtime/inference.py +83 -0
- package/brain/runtime/instrumentation.py +17 -0
- package/brain/runtime/memory/__init__.py +13 -0
- package/brain/runtime/memory/api.py +152 -0
- package/brain/runtime/memory/artifacts.py +33 -0
- package/brain/runtime/memory/candidate.py +89 -0
- package/brain/runtime/memory/context_builder.py +87 -0
- package/brain/runtime/memory/conversation_state.py +1825 -0
- package/brain/runtime/memory/distill.py +198 -0
- package/brain/runtime/memory/embedding_engine.py +94 -0
- package/brain/runtime/memory/freshness.py +91 -0
- package/brain/runtime/memory/health.py +42 -0
- package/brain/runtime/memory/integrity.py +170 -0
- package/brain/runtime/memory/interaction_memory.py +57 -0
- package/brain/runtime/memory/memory_consolidation.py +60 -0
- package/brain/runtime/memory/memory_gate.py +38 -0
- package/brain/runtime/memory/memory_graph.py +54 -0
- package/brain/runtime/memory/memory_links.py +109 -0
- package/brain/runtime/memory/memory_salience.py +235 -0
- package/brain/runtime/memory/memory_synthesis.py +33 -0
- package/brain/runtime/memory/memory_taxonomy.py +35 -0
- package/brain/runtime/memory/person_identity.py +83 -0
- package/brain/runtime/memory/person_memory.py +138 -0
- package/brain/runtime/memory/pondering_engine.py +577 -0
- package/brain/runtime/memory/promote.py +237 -0
- package/brain/runtime/memory/provenance.py +356 -0
- package/brain/runtime/memory/reinforcement.py +73 -0
- package/brain/runtime/memory/retrieval.py +153 -0
- package/brain/runtime/memory/semantic_search.py +66 -0
- package/brain/runtime/memory/sentiment_memory.py +67 -0
- package/brain/runtime/memory/store.py +400 -0
- package/brain/runtime/memory/tool_catalog.py +68 -0
- package/brain/runtime/memory/unresolved_state.py +93 -0
- package/brain/runtime/memory/vector_index.py +270 -0
- package/brain/runtime/model_roles.py +11 -0
- package/brain/runtime/model_router.py +22 -0
- package/brain/runtime/providers.py +59 -0
- package/brain/runtime/security/__init__.py +3 -0
- package/brain/runtime/security/redaction.py +14 -0
- package/brain/runtime/state_store.py +25 -0
- package/brain/runtime/storage_paths.py +41 -0
- package/docs/architecture/memory.md +118 -0
- package/docs/release-checklist.md +34 -0
- package/docs/reports/ocmemog-code-audit-2026-03-14.md +155 -0
- package/docs/usage.md +223 -0
- package/index.ts +726 -0
- package/ocmemog/__init__.py +1 -0
- package/ocmemog/sidecar/__init__.py +1 -0
- package/ocmemog/sidecar/app.py +1068 -0
- package/ocmemog/sidecar/compat.py +74 -0
- package/ocmemog/sidecar/transcript_watcher.py +425 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +60 -0
- package/scripts/install-ocmemog.sh +277 -0
- package/scripts/launchagents/com.openclaw.ocmemog.guard.plist +22 -0
- package/scripts/launchagents/com.openclaw.ocmemog.ponder.plist +22 -0
- package/scripts/launchagents/com.openclaw.ocmemog.sidecar.plist +27 -0
- package/scripts/ocmemog-context.sh +15 -0
- package/scripts/ocmemog-continuity-benchmark.py +178 -0
- package/scripts/ocmemog-demo.py +122 -0
- package/scripts/ocmemog-failover-test.sh +17 -0
- package/scripts/ocmemog-guard.sh +11 -0
- package/scripts/ocmemog-install.sh +93 -0
- package/scripts/ocmemog-load-test.py +106 -0
- package/scripts/ocmemog-ponder.sh +30 -0
- package/scripts/ocmemog-recall-test.py +58 -0
- package/scripts/ocmemog-reindex-vectors.py +14 -0
- package/scripts/ocmemog-reliability-soak.py +177 -0
- package/scripts/ocmemog-sidecar.sh +46 -0
- package/scripts/ocmemog-soak-report.py +58 -0
- package/scripts/ocmemog-soak-test.py +44 -0
- package/scripts/ocmemog-test-rig.py +345 -0
- package/scripts/ocmemog-transcript-append.py +45 -0
- package/scripts/ocmemog-transcript-watcher.py +8 -0
- package/scripts/ocmemog-transcript-watcher.sh +7 -0
package/index.ts
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ENDPOINT = "http://127.0.0.1:17890";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
type PluginConfig = {
|
|
7
|
+
endpoint: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const AUTO_HYDRATION_ENABLED = ["1", "true", "yes"].includes(
|
|
12
|
+
String(process.env.OCMEMOG_AUTO_HYDRATION ?? "false").trim().toLowerCase(),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
type SearchResponse = {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
mode?: string;
|
|
18
|
+
warnings?: string[];
|
|
19
|
+
missingDeps?: string[];
|
|
20
|
+
todo?: string[];
|
|
21
|
+
results?: Array<{
|
|
22
|
+
reference: string;
|
|
23
|
+
bucket?: string;
|
|
24
|
+
score?: number;
|
|
25
|
+
content?: string;
|
|
26
|
+
}>;
|
|
27
|
+
error?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type GetResponse = {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
mode?: string;
|
|
33
|
+
warnings?: string[];
|
|
34
|
+
missingDeps?: string[];
|
|
35
|
+
todo?: string[];
|
|
36
|
+
reference?: string;
|
|
37
|
+
memory?: Record<string, unknown>;
|
|
38
|
+
error?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type RecentResponse = {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
mode?: string;
|
|
44
|
+
warnings?: string[];
|
|
45
|
+
missingDeps?: string[];
|
|
46
|
+
todo?: string[];
|
|
47
|
+
categories?: string[];
|
|
48
|
+
since?: string | null;
|
|
49
|
+
limit?: number;
|
|
50
|
+
results?: Record<string, Array<{ reference: string; timestamp?: string; content?: string }>>;
|
|
51
|
+
error?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ConversationHydrateResponse = {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
recent_turns?: Array<Record<string, unknown>>;
|
|
57
|
+
linked_memories?: Array<{ reference?: string; content?: string }>;
|
|
58
|
+
summary?: Record<string, unknown>;
|
|
59
|
+
state?: Record<string, unknown>;
|
|
60
|
+
error?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ConversationCheckpointResponse = {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
checkpoint?: Record<string, unknown>;
|
|
66
|
+
error?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type ConversationScope = {
|
|
70
|
+
conversation_id?: string;
|
|
71
|
+
session_id?: string;
|
|
72
|
+
thread_id?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function readConfig(raw: unknown): PluginConfig {
|
|
76
|
+
const cfg = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
77
|
+
return {
|
|
78
|
+
endpoint: typeof cfg.endpoint === "string" && cfg.endpoint.trim() ? cfg.endpoint.trim() : DEFAULT_ENDPOINT,
|
|
79
|
+
timeoutMs:
|
|
80
|
+
typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs) && cfg.timeoutMs > 0
|
|
81
|
+
? cfg.timeoutMs
|
|
82
|
+
: DEFAULT_TIMEOUT_MS,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function postJson<T>(config: PluginConfig, path: string, body: Record<string, unknown>): Promise<T> {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(new URL(path, config.endpoint).toString(), {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json" },
|
|
94
|
+
body: JSON.stringify(body),
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const payload = (await response.json()) as T;
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new Error(`sidecar returned HTTP ${response.status}`);
|
|
101
|
+
}
|
|
102
|
+
return payload;
|
|
103
|
+
} finally {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatWarnings(payload: { mode?: string; warnings?: string[]; missingDeps?: string[]; todo?: string[] }): string {
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
if (payload.mode) {
|
|
111
|
+
lines.push(`mode: ${payload.mode}`);
|
|
112
|
+
}
|
|
113
|
+
if (payload.warnings?.length) {
|
|
114
|
+
lines.push(`warnings: ${payload.warnings.join(" | ")}`);
|
|
115
|
+
}
|
|
116
|
+
if (payload.missingDeps?.length) {
|
|
117
|
+
lines.push(`missing deps: ${payload.missingDeps.join(" | ")}`);
|
|
118
|
+
}
|
|
119
|
+
if (payload.todo?.length) {
|
|
120
|
+
lines.push(`todo: ${payload.todo.join(" | ")}`);
|
|
121
|
+
}
|
|
122
|
+
return lines.length ? `\n\n${lines.join("\n")}` : "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
126
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function asString(value: unknown): string {
|
|
130
|
+
return typeof value === "string" ? value.trim() : "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function firstString(...values: unknown[]): string {
|
|
134
|
+
for (const value of values) {
|
|
135
|
+
const text = asString(value);
|
|
136
|
+
if (text) {
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function extractMessageText(message: unknown): string {
|
|
144
|
+
const msg = asRecord(message);
|
|
145
|
+
if (!msg) {
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
if (typeof msg.content === "string") {
|
|
149
|
+
return msg.content.trim();
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(msg.content)) {
|
|
152
|
+
const text = msg.content
|
|
153
|
+
.map((item) => {
|
|
154
|
+
if (typeof item === "string") {
|
|
155
|
+
return item;
|
|
156
|
+
}
|
|
157
|
+
const record = asRecord(item);
|
|
158
|
+
if (!record) {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
return firstString(record.text, record.content, record.input_text, record.output_text);
|
|
162
|
+
})
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.join("\n")
|
|
165
|
+
.trim();
|
|
166
|
+
if (text) {
|
|
167
|
+
return text;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return firstString(msg.text, msg.message, msg.output);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function extractRole(message: unknown): string {
|
|
174
|
+
const msg = asRecord(message);
|
|
175
|
+
if (!msg) {
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
return firstString(msg.role, msg.type).toLowerCase();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extractMessageId(message: unknown): string {
|
|
182
|
+
const msg = asRecord(message);
|
|
183
|
+
if (!msg) {
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
186
|
+
return firstString(msg.id, msg.messageId, msg.message_id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function extractTimestamp(message: unknown): string | undefined {
|
|
190
|
+
const msg = asRecord(message);
|
|
191
|
+
if (!msg) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const raw = msg.timestamp ?? msg.ts ?? msg.createdAt ?? msg.created_at;
|
|
195
|
+
if (typeof raw === "string") {
|
|
196
|
+
return raw;
|
|
197
|
+
}
|
|
198
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
199
|
+
return new Date(raw).toISOString();
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pickScopeFromValue(value: unknown): Partial<ConversationScope> {
|
|
205
|
+
const record = asRecord(value);
|
|
206
|
+
if (!record) {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
conversation_id: firstString(record.conversation_id, record.conversationId),
|
|
211
|
+
session_id: firstString(record.session_id, record.sessionId),
|
|
212
|
+
thread_id: firstString(record.thread_id, record.threadId),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractConversationScope(message: unknown, sessionFallback?: string): ConversationScope {
|
|
217
|
+
const msg = asRecord(message) ?? {};
|
|
218
|
+
const metadata = asRecord(msg.metadata);
|
|
219
|
+
const direct = pickScopeFromValue(msg);
|
|
220
|
+
const metaScope = pickScopeFromValue(metadata);
|
|
221
|
+
return {
|
|
222
|
+
conversation_id: direct.conversation_id || metaScope.conversation_id || undefined,
|
|
223
|
+
session_id: direct.session_id || metaScope.session_id || sessionFallback || undefined,
|
|
224
|
+
thread_id: direct.thread_id || metaScope.thread_id || undefined,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mergeScope(base: ConversationScope, next: Partial<ConversationScope>): ConversationScope {
|
|
229
|
+
return {
|
|
230
|
+
conversation_id: base.conversation_id || next.conversation_id,
|
|
231
|
+
session_id: base.session_id || next.session_id,
|
|
232
|
+
thread_id: base.thread_id || next.thread_id,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resolveHydrationScope(messages: unknown[], ctx: { sessionKey?: string; sessionId?: string }): ConversationScope {
|
|
237
|
+
let scope: ConversationScope = {
|
|
238
|
+
session_id: firstString(ctx.sessionKey, ctx.sessionId) || undefined,
|
|
239
|
+
};
|
|
240
|
+
for (const message of [...messages].reverse()) {
|
|
241
|
+
scope = mergeScope(scope, extractConversationScope(message, scope.session_id));
|
|
242
|
+
if (scope.conversation_id && scope.session_id && scope.thread_id) {
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return scope;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function summarizeList(items: unknown, limit = 3): string[] {
|
|
250
|
+
if (!Array.isArray(items)) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
return items
|
|
254
|
+
.map((item) => {
|
|
255
|
+
const record = asRecord(item);
|
|
256
|
+
return record ? firstString(record.summary, record.content, record.reference) : "";
|
|
257
|
+
})
|
|
258
|
+
.filter(Boolean)
|
|
259
|
+
.slice(0, limit);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const INTERNAL_CONTINUITY_MARKERS = [
|
|
263
|
+
"Memory continuity (auto-hydrated by ocmemog):",
|
|
264
|
+
"Pre-compaction memory flush.",
|
|
265
|
+
"Current time:",
|
|
266
|
+
"Latest user ask:",
|
|
267
|
+
"Last assistant commitment:",
|
|
268
|
+
"Open loops:",
|
|
269
|
+
"Pending actions:",
|
|
270
|
+
"Recent turns:",
|
|
271
|
+
"Linked memories:",
|
|
272
|
+
"Sender (untrusted metadata):",
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
function sanitizeContinuityNoise(text: string, maxLen = 280): string {
|
|
276
|
+
if (!text) {
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
let cleaned = text;
|
|
280
|
+
for (const marker of INTERNAL_CONTINUITY_MARKERS) {
|
|
281
|
+
cleaned = cleaned.split(marker).join(" ");
|
|
282
|
+
}
|
|
283
|
+
cleaned = cleaned
|
|
284
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
285
|
+
.replace(/\b(Memory continuity|Pre-compaction memory flush|Recent turns|Pending actions|Open loops|Linked memories)\b:?/gi, " ")
|
|
286
|
+
.replace(/\s*\|\s*/g, " | ")
|
|
287
|
+
.replace(/\s+/g, " ")
|
|
288
|
+
.trim();
|
|
289
|
+
if (cleaned.length > maxLen) {
|
|
290
|
+
cleaned = `${cleaned.slice(0, maxLen - 1).trim()}…`;
|
|
291
|
+
}
|
|
292
|
+
return cleaned;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildHydrationContext(payload: ConversationHydrateResponse): string {
|
|
296
|
+
if (!payload.ok) {
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
299
|
+
const summary = asRecord(payload.summary);
|
|
300
|
+
const state = asRecord(payload.state);
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
|
|
303
|
+
const checkpoint = asRecord(summary?.latest_checkpoint);
|
|
304
|
+
const checkpointSummary = sanitizeContinuityNoise(firstString(checkpoint?.summary), 140);
|
|
305
|
+
if (checkpointSummary) {
|
|
306
|
+
lines.push(`Checkpoint: ${checkpointSummary}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const latestUserAsk = asRecord(summary?.latest_user_ask);
|
|
310
|
+
const latestUserAskText = sanitizeContinuityNoise(
|
|
311
|
+
firstString(latestUserAsk?.effective_content, latestUserAsk?.content, state?.latest_user_ask),
|
|
312
|
+
220,
|
|
313
|
+
);
|
|
314
|
+
if (latestUserAskText) {
|
|
315
|
+
lines.push(`Latest user ask: ${latestUserAskText}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const commitment = asRecord(summary?.last_assistant_commitment);
|
|
319
|
+
const commitmentText = sanitizeContinuityNoise(firstString(commitment?.content, state?.last_assistant_commitment), 180);
|
|
320
|
+
if (commitmentText) {
|
|
321
|
+
lines.push(`Last assistant commitment: ${commitmentText}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const openLoops = summarizeList(summary?.open_loops, 2).map((item) => sanitizeContinuityNoise(item, 120)).filter(Boolean);
|
|
325
|
+
if (openLoops.length) {
|
|
326
|
+
lines.push(`Open loops: ${openLoops.join(" | ")}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!lines.length) {
|
|
330
|
+
return "";
|
|
331
|
+
}
|
|
332
|
+
return `Memory continuity (auto-hydrated by ocmemog):\n- ${lines.join("\n- ")}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildTurnMetadata(message: unknown, ctx: { agentId?: string; sessionKey?: string }) {
|
|
336
|
+
const msg = asRecord(message) ?? {};
|
|
337
|
+
const metadata = asRecord(msg.metadata) ?? {};
|
|
338
|
+
return {
|
|
339
|
+
...metadata,
|
|
340
|
+
role: extractRole(message) || undefined,
|
|
341
|
+
agent_id: ctx.agentId,
|
|
342
|
+
session_key: ctx.sessionKey,
|
|
343
|
+
reply_to_message_id: firstString(metadata.reply_to_message_id, metadata.replyToMessageId, msg.reply_to_message_id, msg.replyToMessageId) || undefined,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: PluginConfig) {
|
|
348
|
+
api.on("before_message_write", (event, ctx) => {
|
|
349
|
+
try {
|
|
350
|
+
const role = extractRole(event.message);
|
|
351
|
+
if (role !== "user" && role !== "assistant") {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const content = sanitizeContinuityNoise(extractMessageText(event.message), 4000);
|
|
355
|
+
if (!content) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const scope = extractConversationScope(event.message, ctx.sessionKey);
|
|
359
|
+
if (!scope.session_id && !scope.thread_id && !scope.conversation_id) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
void postJson<{ ok: boolean }>(config, "/conversation/ingest_turn", {
|
|
363
|
+
...scope,
|
|
364
|
+
role,
|
|
365
|
+
content,
|
|
366
|
+
message_id: extractMessageId(event.message) || undefined,
|
|
367
|
+
timestamp: extractTimestamp(event.message),
|
|
368
|
+
source: "openclaw.before_message_write",
|
|
369
|
+
metadata: buildTurnMetadata(event.message, ctx),
|
|
370
|
+
}).catch((error) => {
|
|
371
|
+
api.logger.warn(`ocmemog continuity ingest failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
372
|
+
});
|
|
373
|
+
} catch (error) {
|
|
374
|
+
api.logger.warn(`ocmemog continuity ingest scheduling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Safety default (2026-03-18): auto prompt hydration is opt-in.
|
|
379
|
+
// Rationale: continuity wrappers can contribute to prompt bloat/context-window
|
|
380
|
+
// failures if a host runtime persists prepended context into transcript history.
|
|
381
|
+
// Keep the memory backend and sidecar tools active, but only prepend continuity
|
|
382
|
+
// when explicitly enabled and after the host runtime has been validated.
|
|
383
|
+
if (AUTO_HYDRATION_ENABLED) {
|
|
384
|
+
api.on("before_prompt_build", async (event, ctx) => {
|
|
385
|
+
try {
|
|
386
|
+
const scope = resolveHydrationScope(event.messages ?? [], ctx);
|
|
387
|
+
if (!scope.session_id && !scope.thread_id && !scope.conversation_id) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const payload = await postJson<ConversationHydrateResponse>(config, "/conversation/hydrate", {
|
|
391
|
+
...scope,
|
|
392
|
+
turns_limit: 4,
|
|
393
|
+
memory_limit: 3,
|
|
394
|
+
});
|
|
395
|
+
const prependContext = buildHydrationContext(payload);
|
|
396
|
+
if (!prependContext) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
return { prependContext };
|
|
400
|
+
} catch (error) {
|
|
401
|
+
api.logger.warn(`ocmemog answer hydration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
api.logger.info("ocmemog auto prompt hydration disabled (set OCMEMOG_AUTO_HYDRATION=true to re-enable after validating host prompt behavior)");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
api.on("after_compaction", async (_event, ctx) => {
|
|
410
|
+
try {
|
|
411
|
+
const sessionId = firstString(ctx.sessionKey, ctx.sessionId);
|
|
412
|
+
if (!sessionId) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await postJson<ConversationCheckpointResponse>(config, "/conversation/checkpoint", {
|
|
416
|
+
session_id: sessionId,
|
|
417
|
+
checkpoint_kind: "compaction",
|
|
418
|
+
turns_limit: 32,
|
|
419
|
+
});
|
|
420
|
+
} catch (error) {
|
|
421
|
+
api.logger.warn(`ocmemog compaction checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
api.on("session_end", async (_event, ctx) => {
|
|
426
|
+
try {
|
|
427
|
+
const sessionId = firstString(ctx.sessionKey, ctx.sessionId);
|
|
428
|
+
if (!sessionId) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
await postJson<ConversationCheckpointResponse>(config, "/conversation/checkpoint", {
|
|
432
|
+
session_id: sessionId,
|
|
433
|
+
checkpoint_kind: "session_end",
|
|
434
|
+
turns_limit: 48,
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
api.logger.warn(`ocmemog session-end checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const ocmemogPlugin = {
|
|
443
|
+
id: "memory-ocmemog",
|
|
444
|
+
name: "Memory (OCMemog)",
|
|
445
|
+
description: "OC memory plugin backed by the brAIn-derived ocmemog engine.",
|
|
446
|
+
kind: "memory",
|
|
447
|
+
register(api: OpenClawPluginApi) {
|
|
448
|
+
const config = readConfig(api.pluginConfig);
|
|
449
|
+
|
|
450
|
+
registerAutomaticContinuityHooks(api, config);
|
|
451
|
+
|
|
452
|
+
api.registerTool(
|
|
453
|
+
{
|
|
454
|
+
name: "memory_search",
|
|
455
|
+
label: "Memory Search",
|
|
456
|
+
description: "Search the ocmemog sidecar for stored long-term memories.",
|
|
457
|
+
parameters: {
|
|
458
|
+
type: "object",
|
|
459
|
+
additionalProperties: false,
|
|
460
|
+
required: ["query"],
|
|
461
|
+
properties: {
|
|
462
|
+
query: { type: "string", description: "Search query." },
|
|
463
|
+
limit: { type: "number", description: "Maximum results to return." },
|
|
464
|
+
categories: {
|
|
465
|
+
type: "array",
|
|
466
|
+
items: { type: "string" },
|
|
467
|
+
description: "Memory category.",
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
472
|
+
try {
|
|
473
|
+
const payload = await postJson<SearchResponse>(config, "/memory/search", {
|
|
474
|
+
query: params.query,
|
|
475
|
+
limit: params.limit,
|
|
476
|
+
categories: params.categories,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const results = payload.results ?? [];
|
|
480
|
+
const text =
|
|
481
|
+
results.length > 0
|
|
482
|
+
? results
|
|
483
|
+
.map((item, index) => {
|
|
484
|
+
const score = typeof item.score === "number" ? ` (${item.score.toFixed(3)})` : "";
|
|
485
|
+
return `${index + 1}. ${item.reference}${score}\n${String(item.content ?? "").slice(0, 280)}`;
|
|
486
|
+
})
|
|
487
|
+
.join("\n\n")
|
|
488
|
+
: payload.error || "No memories found.";
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
content: [{ type: "text", text: `${text}${formatWarnings(payload)}` }],
|
|
492
|
+
details: payload,
|
|
493
|
+
};
|
|
494
|
+
} catch (error) {
|
|
495
|
+
const message =
|
|
496
|
+
error instanceof Error ? error.message : "unknown sidecar failure";
|
|
497
|
+
return {
|
|
498
|
+
content: [
|
|
499
|
+
{
|
|
500
|
+
type: "text",
|
|
501
|
+
text:
|
|
502
|
+
`ocmemog sidecar request failed for memory_search.\n` +
|
|
503
|
+
`endpoint: ${config.endpoint}\n` +
|
|
504
|
+
`error: ${message}\n` +
|
|
505
|
+
`TODO: start the FastAPI sidecar before using this tool.`,
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
details: { ok: false, endpoint: config.endpoint, error: message },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{ name: "memory_search" },
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
api.registerTool(
|
|
517
|
+
{
|
|
518
|
+
name: "memory_get",
|
|
519
|
+
label: "Memory Get",
|
|
520
|
+
description: "Fetch a memory record by the reference returned from memory_search.",
|
|
521
|
+
parameters: {
|
|
522
|
+
type: "object",
|
|
523
|
+
additionalProperties: false,
|
|
524
|
+
required: ["reference"],
|
|
525
|
+
properties: {
|
|
526
|
+
reference: { type: "string", description: "Memory reference, for example knowledge:12." },
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
530
|
+
try {
|
|
531
|
+
const payload = await postJson<GetResponse>(config, "/memory/get", {
|
|
532
|
+
reference: params.reference,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const text = payload.ok
|
|
536
|
+
? JSON.stringify(payload.memory ?? {}, null, 2)
|
|
537
|
+
: payload.error || "Memory lookup failed.";
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
content: [{ type: "text", text: `${text}${formatWarnings(payload)}` }],
|
|
541
|
+
details: payload,
|
|
542
|
+
};
|
|
543
|
+
} catch (error) {
|
|
544
|
+
const message =
|
|
545
|
+
error instanceof Error ? error.message : "unknown sidecar failure";
|
|
546
|
+
return {
|
|
547
|
+
content: [
|
|
548
|
+
{
|
|
549
|
+
type: "text",
|
|
550
|
+
text:
|
|
551
|
+
`ocmemog sidecar request failed for memory_get.\n` +
|
|
552
|
+
`endpoint: ${config.endpoint}\n` +
|
|
553
|
+
`error: ${message}\n` +
|
|
554
|
+
`TODO: start the FastAPI sidecar before using this tool.`,
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
details: { ok: false, endpoint: config.endpoint, error: message },
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{ name: "memory_get" },
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
api.registerTool(
|
|
566
|
+
{
|
|
567
|
+
name: "memory_recent",
|
|
568
|
+
label: "Memory Recent",
|
|
569
|
+
description: "Fetch recent memories from ocmemog by category and time window.",
|
|
570
|
+
parameters: {
|
|
571
|
+
type: "object",
|
|
572
|
+
additionalProperties: false,
|
|
573
|
+
properties: {
|
|
574
|
+
categories: {
|
|
575
|
+
type: "array",
|
|
576
|
+
items: { type: "string" },
|
|
577
|
+
description: "Filter by memory categories.",
|
|
578
|
+
},
|
|
579
|
+
limit: { type: "number", description: "Maximum items per category." },
|
|
580
|
+
hours: { type: "number", description: "Lookback window in hours." },
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
584
|
+
try {
|
|
585
|
+
const payload = await postJson<RecentResponse>(config, "/memory/recent", {
|
|
586
|
+
categories: params.categories,
|
|
587
|
+
limit: params.limit,
|
|
588
|
+
hours: params.hours,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const results = payload.results ?? {};
|
|
592
|
+
const text = Object.keys(results).length
|
|
593
|
+
? Object.entries(results)
|
|
594
|
+
.map(([category, items]) => {
|
|
595
|
+
const lines = (items || []).map((item, index) =>
|
|
596
|
+
`${index + 1}. ${item.reference}${item.timestamp ? ` (${item.timestamp})` : ""}\n${String(item.content ?? "").slice(0, 240)}`,
|
|
597
|
+
);
|
|
598
|
+
return `## ${category}\n${lines.join("\n\n")}`;
|
|
599
|
+
})
|
|
600
|
+
.join("\n\n")
|
|
601
|
+
: payload.error || "No recent memories found.";
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
content: [{ type: "text", text: `${text}${formatWarnings(payload)}` }],
|
|
605
|
+
details: payload,
|
|
606
|
+
};
|
|
607
|
+
} catch (error) {
|
|
608
|
+
const message =
|
|
609
|
+
error instanceof Error ? error.message : "unknown sidecar failure";
|
|
610
|
+
return {
|
|
611
|
+
content: [
|
|
612
|
+
{
|
|
613
|
+
type: "text",
|
|
614
|
+
text:
|
|
615
|
+
`ocmemog sidecar request failed for memory_recent.\n` +
|
|
616
|
+
`endpoint: ${config.endpoint}\n` +
|
|
617
|
+
`error: ${message}\n` +
|
|
618
|
+
`TODO: start the FastAPI sidecar before using this tool.`,
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
details: { ok: false, endpoint: config.endpoint, error: message },
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
{ name: "memory_recent" },
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
api.registerTool(
|
|
630
|
+
{
|
|
631
|
+
name: "memory_ingest",
|
|
632
|
+
label: "Memory Ingest",
|
|
633
|
+
description: "Ingest raw content into ocmemog as an experience or memory record.",
|
|
634
|
+
parameters: {
|
|
635
|
+
type: "object",
|
|
636
|
+
additionalProperties: false,
|
|
637
|
+
required: ["content"],
|
|
638
|
+
properties: {
|
|
639
|
+
content: { type: "string", description: "Raw content to ingest." },
|
|
640
|
+
kind: { type: "string", description: "experience or memory" },
|
|
641
|
+
memoryType: { type: "string", description: "memory bucket (knowledge/reflections/etc.)" },
|
|
642
|
+
source: { type: "string", description: "Optional source label." },
|
|
643
|
+
taskId: { type: "string", description: "Optional task id for experience ingest." },
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
647
|
+
try {
|
|
648
|
+
const payload = await postJson<{ ok: boolean }>(config, "/memory/ingest", {
|
|
649
|
+
content: params.content,
|
|
650
|
+
kind: params.kind,
|
|
651
|
+
memory_type: params.memoryType,
|
|
652
|
+
source: params.source,
|
|
653
|
+
task_id: params.taskId,
|
|
654
|
+
});
|
|
655
|
+
return {
|
|
656
|
+
content: [{ type: "text", text: `memory_ingest: ${payload.ok ? "ok" : "failed"}` }],
|
|
657
|
+
details: payload,
|
|
658
|
+
};
|
|
659
|
+
} catch (error) {
|
|
660
|
+
const message = error instanceof Error ? error.message : "unknown sidecar failure";
|
|
661
|
+
return {
|
|
662
|
+
content: [
|
|
663
|
+
{
|
|
664
|
+
type: "text",
|
|
665
|
+
text:
|
|
666
|
+
`ocmemog sidecar request failed for memory_ingest.\n` +
|
|
667
|
+
`endpoint: ${config.endpoint}\n` +
|
|
668
|
+
`error: ${message}\n` +
|
|
669
|
+
`TODO: start the FastAPI sidecar before using this tool.`,
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
details: { ok: false, endpoint: config.endpoint, error: message },
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{ name: "memory_ingest" },
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
api.registerTool(
|
|
681
|
+
{
|
|
682
|
+
name: "memory_distill",
|
|
683
|
+
label: "Memory Distill",
|
|
684
|
+
description: "Run a distillation pass on recent experiences in ocmemog.",
|
|
685
|
+
parameters: {
|
|
686
|
+
type: "object",
|
|
687
|
+
additionalProperties: false,
|
|
688
|
+
properties: {
|
|
689
|
+
limit: { type: "number", description: "Max experiences to distill." },
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
693
|
+
try {
|
|
694
|
+
const payload = await postJson<{ ok: boolean; count?: number }>(config, "/memory/distill", {
|
|
695
|
+
limit: params.limit,
|
|
696
|
+
});
|
|
697
|
+
return {
|
|
698
|
+
content: [
|
|
699
|
+
{ type: "text", text: `memory_distill: ${payload.ok ? "ok" : "failed"} (${payload.count ?? 0})` },
|
|
700
|
+
],
|
|
701
|
+
details: payload,
|
|
702
|
+
};
|
|
703
|
+
} catch (error) {
|
|
704
|
+
const message = error instanceof Error ? error.message : "unknown sidecar failure";
|
|
705
|
+
return {
|
|
706
|
+
content: [
|
|
707
|
+
{
|
|
708
|
+
type: "text",
|
|
709
|
+
text:
|
|
710
|
+
`ocmemog sidecar request failed for memory_distill.\n` +
|
|
711
|
+
`endpoint: ${config.endpoint}\n` +
|
|
712
|
+
`error: ${message}\n` +
|
|
713
|
+
`TODO: start the FastAPI sidecar before using this tool.`,
|
|
714
|
+
},
|
|
715
|
+
],
|
|
716
|
+
details: { ok: false, endpoint: config.endpoint, error: message },
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
{ name: "memory_distill" },
|
|
722
|
+
);
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
export default ocmemogPlugin;
|