@nowledge/openclaw-nowledge-mem 0.6.15 → 0.7.1

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,406 @@
1
+ /**
2
+ * Nowledge Mem Context Engine for OpenClaw.
3
+ *
4
+ * Registers alongside the memory slot (kind: "memory"). When users activate
5
+ * this CE via `plugins.slots.contextEngine: "nowledge-mem"`, it replaces the
6
+ * hook-based approach with the richer CE lifecycle:
7
+ *
8
+ * assemble() — behavioral guidance + recalled memories via systemPromptAddition
9
+ * afterTurn() — continuous thread capture + triage/distillation (every turn)
10
+ * compact() — memory-aware compaction (key decisions preserved in summaries)
11
+ * prepareSubagentSpawn() — child sessions inherit relevant memory context
12
+ * bootstrap() — pre-warm Working Memory for first assemble
13
+ *
14
+ * When not activated, the existing hooks (behavioral, recall, capture) work
15
+ * as before — full backward compatibility.
16
+ *
17
+ * Design:
18
+ * - ownsCompaction: false — we enhance compaction instructions, not the algorithm
19
+ * - Messages pass through unchanged — we only add systemPromptAddition
20
+ * - State is per-session (keyed by sessionKey) with bounded cache size
21
+ */
22
+
23
+ import { ceState } from "./ce-state.js";
24
+ import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
25
+ import { appendOrCreateThread, triageAndDistill } from "./hooks/capture.js";
26
+ import {
27
+ MAX_QUERY_LENGTH,
28
+ SHORT_QUERY_THRESHOLD,
29
+ buildRecalledKnowledgeBlock,
30
+ escapeForPrompt,
31
+ } from "./hooks/recall.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Per-session state
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Session context cache: sessionKey -> { wm, memories, lastWmFetch } */
38
+ const _sessions = new Map();
39
+
40
+ /** Subagent memory injection: childSessionKey -> { wm, memories } */
41
+ const _childContext = new Map();
42
+
43
+ const MAX_SESSION_ENTRIES = 100;
44
+ const MAX_CHILD_ENTRIES = 50;
45
+ const WM_CACHE_TTL_MS = 60_000; // 1 min — re-fetch Working Memory after this
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Query building (adapted for CE's assemble params)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Extract plain text from an AgentMessage content field.
53
+ * AgentMessage content can be string or structured blocks.
54
+ */
55
+ function extractText(content) {
56
+ if (typeof content === "string") return content.trim();
57
+ if (!Array.isArray(content)) return "";
58
+ const parts = [];
59
+ for (const block of content) {
60
+ if (!block || typeof block !== "object") continue;
61
+ if (block.type === "text" && typeof block.text === "string") {
62
+ const text = block.text.trim();
63
+ if (text) parts.push(text);
64
+ }
65
+ }
66
+ return parts.join("\n").trim();
67
+ }
68
+
69
+ /**
70
+ * Build a search query from the assemble() params.
71
+ *
72
+ * Uses `prompt` (the user's current input) as the primary signal.
73
+ * Falls back to the last user message in `messages` if prompt is absent.
74
+ * For short queries, augments with recent conversation context.
75
+ */
76
+ function buildAssembleSearchQuery(prompt, messages) {
77
+ // Prefer the prompt parameter (the raw user input for this turn)
78
+ let queryText = typeof prompt === "string" ? prompt.trim() : "";
79
+
80
+ // Fallback: extract last user message from messages array
81
+ if (!queryText && Array.isArray(messages)) {
82
+ for (let i = messages.length - 1; i >= 0; i--) {
83
+ const msg = messages[i];
84
+ if (msg?.role === "user") {
85
+ queryText = extractText(msg.content);
86
+ break;
87
+ }
88
+ }
89
+ }
90
+
91
+ if (!queryText || queryText.length < 3) return "";
92
+
93
+ // Substantial query — use alone
94
+ if (queryText.length >= SHORT_QUERY_THRESHOLD) {
95
+ return queryText.slice(0, MAX_QUERY_LENGTH);
96
+ }
97
+
98
+ // Short query — augment with recent conversation context for topic grounding
99
+ if (Array.isArray(messages) && messages.length > 1) {
100
+ const contextParts = [];
101
+ const start = Math.max(0, messages.length - 4); // last 3 messages before current
102
+ for (let i = start; i < messages.length - 1; i++) {
103
+ const msg = messages[i];
104
+ if (!msg?.role || (msg.role !== "user" && msg.role !== "assistant"))
105
+ continue;
106
+ const text = extractText(msg.content);
107
+ if (!text) continue;
108
+ contextParts.push(text.length > 150 ? `${text.slice(0, 150)}…` : text);
109
+ }
110
+ if (contextParts.length > 0) {
111
+ return `${queryText}\n\n${contextParts.join("\n")}`.slice(
112
+ 0,
113
+ MAX_QUERY_LENGTH,
114
+ );
115
+ }
116
+ }
117
+
118
+ return queryText.slice(0, MAX_QUERY_LENGTH);
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Cache management
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function evictIfNeeded(map, max) {
126
+ if (map.size <= max) return;
127
+ // Evict oldest entries (first inserted)
128
+ const excess = map.size - max;
129
+ let count = 0;
130
+ for (const key of map.keys()) {
131
+ if (count >= excess) break;
132
+ map.delete(key);
133
+ count++;
134
+ }
135
+ }
136
+
137
+ function getSessionState(key) {
138
+ let state = _sessions.get(key);
139
+ if (!state) {
140
+ state = { wm: null, memories: [], lastWmFetch: 0 };
141
+ _sessions.set(key, state);
142
+ evictIfNeeded(_sessions, MAX_SESSION_ENTRIES);
143
+ }
144
+ return state;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Context Engine factory
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Create the CE factory function for api.registerContextEngine().
153
+ *
154
+ * @param {import('./client.js').NowledgeMemClient} client
155
+ * @param {object} cfg Parsed plugin config
156
+ * @param {object} logger OpenClaw logger
157
+ * @returns {() => object} Factory that creates the engine instance
158
+ */
159
+ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
160
+ return () => {
161
+ ceState.active = true;
162
+ logger.info("nowledge-mem: context engine activated");
163
+
164
+ return {
165
+ info: {
166
+ id: "nowledge-mem",
167
+ name: "Nowledge Mem",
168
+ version: "0.7.0",
169
+ ownsCompaction: false,
170
+ },
171
+
172
+ // ------------------------------------------------------------------
173
+ // bootstrap — pre-warm Working Memory for first assemble()
174
+ // ------------------------------------------------------------------
175
+ async bootstrap({ sessionId, sessionKey }) {
176
+ const key = sessionKey || sessionId;
177
+ try {
178
+ const wm = await client.readWorkingMemory();
179
+ const state = getSessionState(key);
180
+ state.wm = wm;
181
+ state.lastWmFetch = Date.now();
182
+ logger.debug?.(`ce: bootstrap — WM loaded for ${key}`);
183
+ return { bootstrapped: true };
184
+ } catch (err) {
185
+ logger.warn(`ce: bootstrap — WM read failed: ${err}`);
186
+ return { bootstrapped: false, reason: String(err) };
187
+ }
188
+ },
189
+
190
+ // ------------------------------------------------------------------
191
+ // ingest / ingestBatch — lightweight; real capture is in afterTurn
192
+ // ------------------------------------------------------------------
193
+ async ingest({ isHeartbeat }) {
194
+ return { ingested: !isHeartbeat };
195
+ },
196
+
197
+ async ingestBatch({ messages, isHeartbeat }) {
198
+ return { ingestedCount: isHeartbeat ? 0 : (messages?.length ?? 0) };
199
+ },
200
+
201
+ // ------------------------------------------------------------------
202
+ // assemble — behavioral guidance + recalled memories in systemPromptAddition
203
+ //
204
+ // Messages pass through unchanged. We never own message selection —
205
+ // the runtime's sanitize → validate → limit pipeline handles that.
206
+ // ------------------------------------------------------------------
207
+ async assemble({ sessionId, sessionKey, messages, prompt }) {
208
+ const key = sessionKey || sessionId;
209
+ const state = getSessionState(key);
210
+ const sections = [];
211
+
212
+ // 1. Behavioral guidance (always — ~50 tokens)
213
+ sections.push(
214
+ cfg.sessionContext ? SESSION_CONTEXT_GUIDANCE : BASE_GUIDANCE,
215
+ );
216
+
217
+ // 2. Working Memory (refresh if stale)
218
+ try {
219
+ if (!state.wm || Date.now() - state.lastWmFetch > WM_CACHE_TTL_MS) {
220
+ state.wm = await client.readWorkingMemory();
221
+ state.lastWmFetch = Date.now();
222
+ }
223
+ if (state.wm?.available) {
224
+ sections.push(
225
+ `<working-memory>\n${escapeForPrompt(state.wm.content)}\n</working-memory>`,
226
+ );
227
+ }
228
+ } catch (err) {
229
+ logger.debug?.(`ce: assemble — WM read failed: ${err}`);
230
+ }
231
+
232
+ // 3. Recalled memories (when sessionContext enabled)
233
+ if (cfg.sessionContext) {
234
+ const query = buildAssembleSearchQuery(prompt, messages);
235
+ if (query) {
236
+ try {
237
+ const results = await client.searchRich(
238
+ query,
239
+ cfg.maxContextResults,
240
+ );
241
+ const minScore = (cfg.recallMinScore ?? 0) / 100;
242
+ const filtered =
243
+ minScore > 0
244
+ ? results.filter((r) => (r.score ?? 0) >= minScore)
245
+ : results;
246
+ if (filtered.length > 0) {
247
+ state.memories = filtered; // cache for compact()
248
+ sections.push(buildRecalledKnowledgeBlock(filtered));
249
+ }
250
+ } catch (err) {
251
+ logger.debug?.(`ce: assemble — recall failed: ${err}`);
252
+ }
253
+ }
254
+ }
255
+
256
+ // 4. Subagent memory injection (one-time for child sessions)
257
+ const childCtx = _childContext.get(key);
258
+ if (childCtx) {
259
+ if (childCtx.wm?.available) {
260
+ sections.push(
261
+ `<parent-context>\n${escapeForPrompt(childCtx.wm.content)}\n</parent-context>`,
262
+ );
263
+ }
264
+ if (childCtx.memories?.length > 0) {
265
+ sections.push(
266
+ buildRecalledKnowledgeBlock(
267
+ childCtx.memories,
268
+ "parent-knowledge",
269
+ ),
270
+ );
271
+ }
272
+ _childContext.delete(key);
273
+ }
274
+
275
+ _sessions.set(key, state);
276
+
277
+ const systemPromptAddition =
278
+ sections.length > 1
279
+ ? `<nowledge-mem>\n${sections.join("\n\n")}\n</nowledge-mem>`
280
+ : sections.length === 1
281
+ ? sections[0] // just the behavioral guidance, skip wrapper
282
+ : undefined;
283
+
284
+ return { messages, estimatedTokens: 0, systemPromptAddition };
285
+ },
286
+
287
+ // ------------------------------------------------------------------
288
+ // compact — enhance compaction with memory context, then delegate
289
+ //
290
+ // When key decisions/learnings from this conversation have been saved
291
+ // to the knowledge graph, we tell the compactor so it can reference
292
+ // them concisely rather than losing them in summarization.
293
+ // ------------------------------------------------------------------
294
+ async compact(params) {
295
+ const key = params.sessionKey || params.sessionId;
296
+ const state = _sessions.get(key);
297
+
298
+ let enhanced = params.customInstructions || "";
299
+ if (state?.memories?.length > 0) {
300
+ const memoryHints = state.memories
301
+ .slice(0, 8)
302
+ .map((m) => {
303
+ const title = m.title || "(untitled)";
304
+ const snippet = (m.content || "").slice(0, 120);
305
+ return `- ${title}: ${snippet}`;
306
+ })
307
+ .join("\n");
308
+ enhanced += `\n\nThe user has the following knowledge saved in their personal knowledge graph (Nowledge Mem). When compacting older messages, reference these items by name rather than repeating them in full — the complete version is preserved in the graph:\n${memoryHints}`;
309
+ }
310
+
311
+ try {
312
+ const { delegateCompactionToRuntime } = await import(
313
+ "openclaw/plugin-sdk/core"
314
+ );
315
+ return delegateCompactionToRuntime({
316
+ ...params,
317
+ customInstructions: enhanced.trim() || undefined,
318
+ });
319
+ } catch (err) {
320
+ logger.warn(`ce: compact delegation failed: ${err}`);
321
+ // Tell the runtime we couldn't compact — it will handle overflow recovery
322
+ return {
323
+ ok: true,
324
+ compacted: false,
325
+ reason: "delegation-unavailable",
326
+ };
327
+ }
328
+ },
329
+
330
+ // ------------------------------------------------------------------
331
+ // afterTurn — continuous thread capture + triage/distillation
332
+ //
333
+ // Fires after every turn (more granular than agent_end hook).
334
+ // The dedup layer in appendOrCreateThread ensures no duplicates.
335
+ // ------------------------------------------------------------------
336
+ async afterTurn({
337
+ sessionId,
338
+ sessionKey,
339
+ sessionFile,
340
+ messages,
341
+ isHeartbeat,
342
+ }) {
343
+ if (isHeartbeat) return;
344
+
345
+ const event = { messages, sessionFile };
346
+ const ctx = { sessionId, sessionKey };
347
+
348
+ // 1. Always capture thread (idempotent, deduped)
349
+ const captureResult = await appendOrCreateThread({
350
+ client,
351
+ logger,
352
+ event,
353
+ ctx,
354
+ reason: "turn",
355
+ maxMessageChars: cfg.maxThreadMessageChars,
356
+ });
357
+
358
+ // 2. Triage + distill (shared logic with agent_end path)
359
+ await triageAndDistill({ client, cfg, logger, captureResult, ctx });
360
+ },
361
+
362
+ // ------------------------------------------------------------------
363
+ // prepareSubagentSpawn — propagate memory context to child sessions
364
+ //
365
+ // When OpenClaw spawns parallel research agents, they start without
366
+ // memory context. We inject the parent's Working Memory and recently
367
+ // recalled memories so the child has relevant background.
368
+ // ------------------------------------------------------------------
369
+ async prepareSubagentSpawn({ parentSessionKey, childSessionKey }) {
370
+ try {
371
+ const parentState = _sessions.get(parentSessionKey);
372
+ if (parentState) {
373
+ _childContext.set(childSessionKey, {
374
+ wm: parentState.wm,
375
+ memories: (parentState.memories || []).slice(0, 3),
376
+ });
377
+ evictIfNeeded(_childContext, MAX_CHILD_ENTRIES);
378
+ }
379
+ return {
380
+ rollback: () => _childContext.delete(childSessionKey),
381
+ };
382
+ } catch (err) {
383
+ logger.debug?.(`ce: subagent spawn prep failed: ${err}`);
384
+ return undefined;
385
+ }
386
+ },
387
+
388
+ // ------------------------------------------------------------------
389
+ // onSubagentEnded — clean up child context cache
390
+ // ------------------------------------------------------------------
391
+ async onSubagentEnded({ childSessionKey }) {
392
+ _childContext.delete(childSessionKey);
393
+ },
394
+
395
+ // ------------------------------------------------------------------
396
+ // dispose — clean up all state
397
+ // ------------------------------------------------------------------
398
+ async dispose() {
399
+ ceState.active = false;
400
+ _sessions.clear();
401
+ _childContext.clear();
402
+ logger.info("nowledge-mem: context engine disposed");
403
+ },
404
+ };
405
+ };
406
+ }
@@ -10,9 +10,14 @@
10
10
  * When sessionContext is enabled, guidance is adjusted to note that
11
11
  * relevant memories are already injected — reducing redundant searches
12
12
  * while keeping the save nudge and thread awareness.
13
+ *
14
+ * When the context engine is active, this hook is a no-op —
15
+ * assemble() handles behavioral guidance via systemPromptAddition.
13
16
  */
14
17
 
15
- const BASE_GUIDANCE = [
18
+ import { ceState } from "../ce-state.js";
19
+
20
+ export const BASE_GUIDANCE = [
16
21
  "<nowledge-mem-guidance>",
17
22
  "You have access to the user's personal knowledge graph (Nowledge Mem).",
18
23
  "Before answering questions about prior work, decisions, dates, people, preferences, or plans:",
@@ -23,7 +28,7 @@ const BASE_GUIDANCE = [
23
28
  "</nowledge-mem-guidance>",
24
29
  ].join("\n");
25
30
 
26
- const SESSION_CONTEXT_GUIDANCE = [
31
+ export const SESSION_CONTEXT_GUIDANCE = [
27
32
  "<nowledge-mem-guidance>",
28
33
  "You have access to the user's personal knowledge graph (Nowledge Mem).",
29
34
  "Relevant memories and your Working Memory have already been injected into this prompt.",
@@ -37,6 +42,7 @@ const SESSION_CONTEXT_GUIDANCE = [
37
42
  export function buildBehavioralHook(logger, { sessionContext = false } = {}) {
38
43
  const guidance = sessionContext ? SESSION_CONTEXT_GUIDANCE : BASE_GUIDANCE;
39
44
  return (_event, _ctx) => {
45
+ if (ceState.active) return;
40
46
  logger.debug?.("behavioral: injecting guidance");
41
47
  return { appendSystemContext: guidance };
42
48
  };