shieldcortex 2.16.2 → 2.17.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.
Files changed (102) hide show
  1. package/README.md +322 -465
  2. package/dashboard/.next/standalone/dashboard/.next/BUILD_ID +1 -1
  3. package/dashboard/.next/standalone/dashboard/.next/build-manifest.json +2 -2
  4. package/dashboard/.next/standalone/dashboard/.next/prerender-manifest.json +3 -3
  5. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.html +2 -2
  6. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.rsc +1 -1
  7. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.html +1 -1
  14. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.rsc +2 -2
  15. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/dashboard/.next/standalone/dashboard/.next/server/app/index.html +1 -1
  22. package/dashboard/.next/standalone/dashboard/.next/server/app/index.rsc +3 -3
  23. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  24. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_full.segment.rsc +3 -3
  25. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_head.segment.rsc +1 -1
  26. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_index.segment.rsc +2 -2
  27. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  28. package/dashboard/.next/standalone/dashboard/.next/server/app/page_client-reference-manifest.js +1 -1
  29. package/dashboard/.next/standalone/dashboard/.next/server/chunks/ssr/dashboard_25b1b286._.js +1 -1
  30. package/dashboard/.next/standalone/dashboard/.next/server/pages/404.html +1 -1
  31. package/dashboard/.next/standalone/dashboard/.next/server/pages/500.html +2 -2
  32. package/dashboard/.next/standalone/dashboard/.next/server/server-reference-manifest.js +1 -1
  33. package/dashboard/.next/standalone/dashboard/.next/server/server-reference-manifest.json +1 -1
  34. package/dashboard/.next/standalone/dashboard/.next/static/chunks/{1bf33aa1c01418e1.js → 61a3c89b08347bc2.js} +1 -1
  35. package/dashboard/.next/standalone/dashboard/.next/static/chunks/c252c4de65df6d09.css +3 -0
  36. package/dist/api/visualization-server.d.ts.map +1 -1
  37. package/dist/api/visualization-server.js +30 -2
  38. package/dist/api/visualization-server.js.map +1 -1
  39. package/dist/cloud/cli.d.ts.map +1 -1
  40. package/dist/cloud/cli.js +21 -1
  41. package/dist/cloud/cli.js.map +1 -1
  42. package/dist/cloud/config.d.ts +23 -0
  43. package/dist/cloud/config.d.ts.map +1 -1
  44. package/dist/cloud/config.js +57 -0
  45. package/dist/cloud/config.js.map +1 -1
  46. package/dist/defence/__tests__/pipeline.test.js +45 -1
  47. package/dist/defence/__tests__/pipeline.test.js.map +1 -1
  48. package/dist/defence/index.d.ts +2 -2
  49. package/dist/defence/index.d.ts.map +1 -1
  50. package/dist/defence/index.js +1 -1
  51. package/dist/defence/index.js.map +1 -1
  52. package/dist/defence/pipeline.d.ts.map +1 -1
  53. package/dist/defence/pipeline.js +4 -0
  54. package/dist/defence/pipeline.js.map +1 -1
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +2 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/integrations/__tests__/openclaw.test.d.ts +2 -0
  60. package/dist/integrations/__tests__/openclaw.test.d.ts.map +1 -0
  61. package/dist/integrations/__tests__/openclaw.test.js +72 -0
  62. package/dist/integrations/__tests__/openclaw.test.js.map +1 -0
  63. package/dist/integrations/__tests__/universal.test.d.ts +2 -0
  64. package/dist/integrations/__tests__/universal.test.d.ts.map +1 -0
  65. package/dist/integrations/__tests__/universal.test.js +144 -0
  66. package/dist/integrations/__tests__/universal.test.js.map +1 -0
  67. package/dist/integrations/index.d.ts +3 -0
  68. package/dist/integrations/index.d.ts.map +1 -1
  69. package/dist/integrations/index.js +2 -0
  70. package/dist/integrations/index.js.map +1 -1
  71. package/dist/integrations/openclaw.d.ts +38 -0
  72. package/dist/integrations/openclaw.d.ts.map +1 -0
  73. package/dist/integrations/openclaw.js +169 -0
  74. package/dist/integrations/openclaw.js.map +1 -0
  75. package/dist/integrations/universal.d.ts +62 -0
  76. package/dist/integrations/universal.d.ts.map +1 -0
  77. package/dist/integrations/universal.js +100 -0
  78. package/dist/integrations/universal.js.map +1 -0
  79. package/dist/lib.d.ts +3 -1
  80. package/dist/lib.d.ts.map +1 -1
  81. package/dist/lib.js +3 -1
  82. package/dist/lib.js.map +1 -1
  83. package/dist/setup/openclaw.d.ts.map +1 -1
  84. package/dist/setup/openclaw.js +3 -2
  85. package/dist/setup/openclaw.js.map +1 -1
  86. package/dist/setup/status.d.ts.map +1 -1
  87. package/dist/setup/status.js +34 -0
  88. package/dist/setup/status.js.map +1 -1
  89. package/dist/setup/uninstall.d.ts.map +1 -1
  90. package/dist/setup/uninstall.js +6 -1
  91. package/dist/setup/uninstall.js.map +1 -1
  92. package/hooks/openclaw/cortex-memory/HOOK.md +32 -2
  93. package/hooks/openclaw/cortex-memory/handler.ts +213 -10
  94. package/package.json +9 -1
  95. package/plugins/openclaw/README.md +38 -2
  96. package/plugins/openclaw/dist/index.js +145 -5
  97. package/plugins/openclaw/index.ts +183 -6
  98. package/scripts/postinstall.mjs +34 -0
  99. package/dashboard/.next/standalone/dashboard/.next/static/chunks/bccda52164e63171.css +0 -3
  100. /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → H-BGC5Yp6YmPEZGryV6bd}/_buildManifest.js +0 -0
  101. /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → H-BGC5Yp6YmPEZGryV6bd}/_clientMiddlewareManifest.json +0 -0
  102. /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → H-BGC5Yp6YmPEZGryV6bd}/_ssgManifest.js +0 -0
@@ -7,11 +7,34 @@
7
7
  * - Keyword-triggered memory saves
8
8
  */
9
9
  import { execFile } from "node:child_process";
10
+ import { createHash } from "node:crypto";
10
11
  import fs from "node:fs/promises";
12
+ import { homedir } from "node:os";
11
13
  import path from "node:path";
12
14
 
13
15
  // ==================== SERVER COMMAND RESOLUTION ====================
14
16
 
17
+ let _shieldConfig = null;
18
+ let _autoMemoryNoticeShown = false;
19
+
20
+ async function loadShieldConfig() {
21
+ if (_shieldConfig) return _shieldConfig;
22
+
23
+ try {
24
+ const configPath = path.join(homedir(), ".shieldcortex", "config.json");
25
+ _shieldConfig = JSON.parse(await fs.readFile(configPath, "utf-8"));
26
+ } catch {
27
+ _shieldConfig = {};
28
+ }
29
+
30
+ return _shieldConfig;
31
+ }
32
+
33
+ async function isOpenClawAutoMemoryEnabled() {
34
+ const config = await loadShieldConfig();
35
+ return config?.openclawAutoMemory === true;
36
+ }
37
+
15
38
  /**
16
39
  * Resolve the fastest way to invoke shieldcortex:
17
40
  * 1. ~/.shieldcortex/config.json "binaryPath" override
@@ -23,14 +46,10 @@ let _resolvedServerCmd = null;
23
46
  async function resolveServerCmd() {
24
47
  if (_resolvedServerCmd) return _resolvedServerCmd;
25
48
 
26
- const path = await import("node:path");
27
- const { homedir } = await import("node:os");
28
-
29
49
  // 1. Check config for explicit binaryPath
30
50
  try {
31
- const configPath = path.join(homedir(), ".shieldcortex", "config.json");
32
- const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
33
- if (config.binaryPath) {
51
+ const config = await loadShieldConfig();
52
+ if (config?.binaryPath) {
34
53
  await fs.access(config.binaryPath);
35
54
  _resolvedServerCmd = config.binaryPath;
36
55
  console.log(`[cortex-memory] Using configured binary: ${config.binaryPath}`);
@@ -115,6 +134,150 @@ async function callCortex(tool, args = {}, options = { retries: 1, timeout: 1500
115
134
  });
116
135
  }
117
136
 
137
+ // ==================== NOVELTY / DEDUPE GATE ====================
138
+
139
+ const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
140
+ const DEFAULT_NOVELTY_THRESHOLD = 0.88;
141
+ const DEFAULT_MAX_RECENT = 300;
142
+ const MIN_NOVELTY_CHARS = 40;
143
+
144
+ function normalizeMemoryText(text) {
145
+ return String(text || "")
146
+ .toLowerCase()
147
+ .replace(/[`"'\\]/g, " ")
148
+ .replace(/https?:\/\/\S+/g, " ")
149
+ .replace(/[^a-z0-9\s]/g, " ")
150
+ .replace(/\s+/g, " ")
151
+ .trim();
152
+ }
153
+
154
+ function hashToken(token) {
155
+ return createHash("sha1").update(token).digest("hex").slice(0, 12);
156
+ }
157
+
158
+ function buildTokenHashes(normalized) {
159
+ const words = normalized.split(" ").filter((w) => w.length >= 3);
160
+ const set = new Set();
161
+
162
+ for (let i = 0; i < words.length; i++) {
163
+ set.add(hashToken(words[i]));
164
+ if (i < words.length - 1) {
165
+ set.add(hashToken(`${words[i]}_${words[i + 1]}`));
166
+ }
167
+ }
168
+
169
+ return Array.from(set).slice(0, 200);
170
+ }
171
+
172
+ function jaccardSimilarity(a, b) {
173
+ if (a.size === 0 || b.size === 0) return 0;
174
+ let intersection = 0;
175
+ for (const item of a) {
176
+ if (b.has(item)) intersection++;
177
+ }
178
+ const union = a.size + b.size - intersection;
179
+ return union === 0 ? 0 : intersection / union;
180
+ }
181
+
182
+ function clamp(value, min, max) {
183
+ return Math.max(min, Math.min(max, value));
184
+ }
185
+
186
+ async function getNoveltyConfig() {
187
+ const config = await loadShieldConfig();
188
+ const rawThreshold = Number(config?.openclawAutoMemoryNoveltyThreshold);
189
+ const rawMaxRecent = Number(config?.openclawAutoMemoryMaxRecent);
190
+ return {
191
+ enabled: config?.openclawAutoMemoryDedupe !== false,
192
+ threshold: Number.isFinite(rawThreshold)
193
+ ? clamp(rawThreshold, 0.6, 0.99)
194
+ : DEFAULT_NOVELTY_THRESHOLD,
195
+ maxRecent: Number.isFinite(rawMaxRecent)
196
+ ? Math.floor(clamp(rawMaxRecent, 50, 1000))
197
+ : DEFAULT_MAX_RECENT,
198
+ };
199
+ }
200
+
201
+ async function loadNoveltyCache(maxRecent) {
202
+ try {
203
+ const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
204
+ if (!Array.isArray(raw)) return [];
205
+ return raw
206
+ .filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
207
+ .slice(0, maxRecent);
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ async function saveNoveltyCache(entries) {
214
+ await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
215
+ await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
216
+ }
217
+
218
+ function inspectNovelty(content, entries, threshold) {
219
+ const normalized = normalizeMemoryText(content);
220
+ if (normalized.length < MIN_NOVELTY_CHARS) {
221
+ return { allow: true, normalized, contentHash: null, tokenHashes: [] };
222
+ }
223
+
224
+ const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
225
+ if (entries.some((entry) => entry.hash === contentHash)) {
226
+ return { allow: false, normalized, contentHash, tokenHashes: [], reason: "exact duplicate" };
227
+ }
228
+
229
+ const tokenHashes = buildTokenHashes(normalized);
230
+ const currentSet = new Set(tokenHashes);
231
+ let bestSimilarity = 0;
232
+
233
+ for (const entry of entries) {
234
+ const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
235
+ if (score > bestSimilarity) bestSimilarity = score;
236
+ if (score >= threshold) {
237
+ return {
238
+ allow: false,
239
+ normalized,
240
+ contentHash,
241
+ tokenHashes,
242
+ reason: `near duplicate (similarity ${score.toFixed(2)})`,
243
+ };
244
+ }
245
+ }
246
+
247
+ return { allow: true, normalized, contentHash, tokenHashes, bestSimilarity };
248
+ }
249
+
250
+ async function createNoveltyGate() {
251
+ const cfg = await getNoveltyConfig();
252
+ const entries = cfg.enabled ? await loadNoveltyCache(cfg.maxRecent) : [];
253
+ let dirty = false;
254
+
255
+ return {
256
+ enabled: cfg.enabled,
257
+ inspect(content) {
258
+ if (!cfg.enabled) return { allow: true, reason: null };
259
+ return inspectNovelty(content, entries, cfg.threshold);
260
+ },
261
+ remember(memory, novelty) {
262
+ if (!cfg.enabled) return;
263
+ if (!novelty?.contentHash || !Array.isArray(novelty?.tokenHashes)) return;
264
+ entries.unshift({
265
+ hash: novelty.contentHash,
266
+ tokenHashes: novelty.tokenHashes,
267
+ title: String(memory?.title || "").slice(0, 120),
268
+ category: String(memory?.category || "note"),
269
+ createdAt: new Date().toISOString(),
270
+ });
271
+ if (entries.length > cfg.maxRecent) entries.length = cfg.maxRecent;
272
+ dirty = true;
273
+ },
274
+ async flush() {
275
+ if (!cfg.enabled || !dirty) return;
276
+ await saveNoveltyCache(entries);
277
+ },
278
+ };
279
+ }
280
+
118
281
  // ==================== HOOK SCANNER ====================
119
282
 
120
283
  /**
@@ -282,6 +445,14 @@ async function getRecentMessages(sessionFilePath) {
282
445
  * Handle command:new — extract memories from ending session
283
446
  */
284
447
  async function onSessionEnd(event) {
448
+ if (!(await isOpenClawAutoMemoryEnabled())) {
449
+ if (!_autoMemoryNoticeShown) {
450
+ console.log("[cortex-memory] Auto memory extraction disabled (set openclawAutoMemory=true to enable)");
451
+ _autoMemoryNoticeShown = true;
452
+ }
453
+ return;
454
+ }
455
+
285
456
  const context = event.context || {};
286
457
  const sessionEntry = context.previousSessionEntry || context.sessionEntry || {};
287
458
  const sessionFile = sessionEntry.sessionFile;
@@ -303,8 +474,16 @@ async function onSessionEnd(event) {
303
474
  return;
304
475
  }
305
476
 
477
+ const noveltyGate = await createNoveltyGate();
306
478
  let saved = 0;
479
+ let skipped = 0;
307
480
  for (const mem of memories) {
481
+ const novelty = noveltyGate.inspect(mem.content);
482
+ if (!novelty.allow) {
483
+ skipped++;
484
+ continue;
485
+ }
486
+
308
487
  const result = await callCortex("remember", {
309
488
  title: mem.title,
310
489
  content: mem.content,
@@ -314,10 +493,14 @@ async function onSessionEnd(event) {
314
493
  importance: "high",
315
494
  tags: "auto-extracted,openclaw-hook",
316
495
  });
317
- if (result) saved++;
496
+ if (result) {
497
+ saved++;
498
+ noveltyGate.remember(mem, novelty);
499
+ }
318
500
  }
501
+ await noveltyGate.flush();
319
502
 
320
- console.log(`[cortex-memory] Saved ${saved}/${memories.length} memories from session`);
503
+ console.log(`[cortex-memory] Saved ${saved}/${memories.length} memories from session (${skipped} skipped as duplicates)`);
321
504
 
322
505
  // Provide visible feedback to user
323
506
  if (saved > 0 && event.messages) {
@@ -330,6 +513,14 @@ async function onSessionEnd(event) {
330
513
  * This fires when user explicitly calls /stop
331
514
  */
332
515
  async function onSessionStop(event) {
516
+ if (!(await isOpenClawAutoMemoryEnabled())) {
517
+ if (!_autoMemoryNoticeShown) {
518
+ console.log("[cortex-memory] Auto memory extraction disabled (set openclawAutoMemory=true to enable)");
519
+ _autoMemoryNoticeShown = true;
520
+ }
521
+ return;
522
+ }
523
+
333
524
  const context = event.context || {};
334
525
  const sessionEntry = context.sessionEntry || {};
335
526
  const sessionFile = sessionEntry.sessionFile;
@@ -351,8 +542,16 @@ async function onSessionStop(event) {
351
542
  return;
352
543
  }
353
544
 
545
+ const noveltyGate = await createNoveltyGate();
354
546
  let saved = 0;
547
+ let skipped = 0;
355
548
  for (const mem of memories) {
549
+ const novelty = noveltyGate.inspect(mem.content);
550
+ if (!novelty.allow) {
551
+ skipped++;
552
+ continue;
553
+ }
554
+
356
555
  const result = await callCortex("remember", {
357
556
  title: mem.title,
358
557
  content: mem.content,
@@ -362,10 +561,14 @@ async function onSessionStop(event) {
362
561
  importance: "high",
363
562
  tags: "auto-extracted,openclaw-hook,session-stop",
364
563
  });
365
- if (result) saved++;
564
+ if (result) {
565
+ saved++;
566
+ noveltyGate.remember(mem, novelty);
567
+ }
366
568
  }
569
+ await noveltyGate.flush();
367
570
 
368
- console.log(`[cortex-memory] Saved ${saved}/${memories.length} memories on session stop`);
571
+ console.log(`[cortex-memory] Saved ${saved}/${memories.length} memories on session stop (${skipped} skipped as duplicates)`);
369
572
 
370
573
  // Provide visible feedback to user
371
574
  if (saved > 0 && event.messages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shieldcortex",
3
- "version": "2.16.2",
3
+ "version": "2.17.1",
4
4
  "description": "Persistent brain for AI agents. Knowledge graphs, memory decay, contradiction detection, Iron Dome behaviour protection — plus the only defence pipeline that stops memory poisoning. Works with Claude Code, OpenClaw, LangChain, and any MCP agent.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,6 +14,14 @@
14
14
  "import": "./dist/integrations/langchain.js",
15
15
  "types": "./dist/integrations/langchain.d.ts"
16
16
  },
17
+ "./integrations/universal": {
18
+ "import": "./dist/integrations/universal.js",
19
+ "types": "./dist/integrations/universal.d.ts"
20
+ },
21
+ "./integrations/openclaw": {
22
+ "import": "./dist/integrations/openclaw.js",
23
+ "types": "./dist/integrations/openclaw.d.ts"
24
+ },
17
25
  "./integrations": {
18
26
  "import": "./dist/integrations/index.js",
19
27
  "types": "./dist/integrations/index.d.ts"
@@ -7,7 +7,7 @@ Real-time defence scanning and memory extraction for OpenClaw v2026.2.15+.
7
7
  | Hook | Action |
8
8
  |------|--------|
9
9
  | `llm_input` | Scans prompts + history through ShieldCortex defence pipeline. Logs threats, writes audit log, optionally syncs to cloud. **Fire-and-forget.** |
10
- | `llm_output` | Extracts decisions, fixes, learnings from assistant responses via pattern matching + salience scoring. Saves to ShieldCortex memory via mcporter. **Fire-and-forget.** |
10
+ | `llm_output` | Optional memory extraction from assistant responses (disabled by default). Saves to ShieldCortex memory via mcporter when enabled, with novelty/dedupe filtering to reduce noise. **Fire-and-forget.** |
11
11
 
12
12
  ## Installation
13
13
 
@@ -39,6 +39,42 @@ Find the package root with `npm root -g` (global) or `npm root` (local).
39
39
  - ShieldCortex installed globally (`npm i -g shieldcortex`) or at `~/ShieldCortex/`
40
40
  - `mcporter` available via npx (for memory saves)
41
41
 
42
+ ## Optional Auto-Memory
43
+
44
+ Auto-memory extraction is off by default to avoid duplicate/noisy memory when OpenClaw already has native memory.
45
+
46
+ Enable it:
47
+
48
+ ```bash
49
+ npx shieldcortex config --openclaw-auto-memory true
50
+ ```
51
+
52
+ Disable it:
53
+
54
+ ```bash
55
+ npx shieldcortex config --openclaw-auto-memory false
56
+ ```
57
+
58
+ Enable it in `~/.shieldcortex/config.json`:
59
+
60
+ ```json
61
+ {
62
+ "openclawAutoMemory": true
63
+ }
64
+ ```
65
+
66
+ Novelty filtering is enabled by default when auto-memory is on. Optional tuning keys:
67
+
68
+ ```json
69
+ {
70
+ "openclawAutoMemoryDedupe": true,
71
+ "openclawAutoMemoryNoveltyThreshold": 0.88,
72
+ "openclawAutoMemoryMaxRecent": 300
73
+ }
74
+ ```
75
+
76
+ You can also manage these settings from the local dashboard in `Shield Overview -> OpenClaw Memory`.
77
+
42
78
  ## Cloud Sync (optional)
43
79
 
44
80
  Add your API key to `~/.shieldcortex/config.json`:
@@ -46,7 +82,7 @@ Add your API key to `~/.shieldcortex/config.json`:
46
82
  ```json
47
83
  {
48
84
  "cloudApiKey": "sc_...",
49
- "cloudEndpoint": "https://api.shieldcortex.ai"
85
+ "cloudBaseUrl": "https://api.shieldcortex.ai"
50
86
  }
51
87
  ```
52
88
 
@@ -2,9 +2,10 @@
2
2
  * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.2.15+
3
3
  *
4
4
  * Hooks into llm_input/llm_output for real-time defence scanning
5
- * and memory extraction. All operations are fire-and-forget.
5
+ * and optional memory extraction. All operations are fire-and-forget.
6
6
  */
7
7
  import { execFile } from "node:child_process";
8
+ import { createHash } from "node:crypto";
8
9
  import fs from "node:fs/promises";
9
10
  import path from "node:path";
10
11
  import { homedir } from "node:os";
@@ -20,6 +21,12 @@ async function loadConfig() {
20
21
  }
21
22
  return _config;
22
23
  }
24
+ function isAutoMemoryEnabled(config) {
25
+ return config.openclawAutoMemory === true;
26
+ }
27
+ function isAutoMemoryDedupeEnabled(config) {
28
+ return config.openclawAutoMemoryDedupe !== false;
29
+ }
23
30
  // ==================== SERVER CMD ====================
24
31
  let _serverCmd = null;
25
32
  async function resolveServerCmd() {
@@ -154,6 +161,10 @@ function extractUserContent(msgs) {
154
161
  return out;
155
162
  }
156
163
  const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
164
+ const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
165
+ const DEFAULT_NOVELTY_THRESHOLD = 0.88;
166
+ const DEFAULT_MAX_RECENT = 300;
167
+ const MIN_NOVELTY_CHARS = 40;
157
168
  async function auditLog(entry) {
158
169
  try {
159
170
  await fs.mkdir(AUDIT_DIR, { recursive: true });
@@ -175,6 +186,122 @@ async function cloudSync(threat) {
175
186
  }
176
187
  catch { }
177
188
  }
189
+ function normalizeMemoryText(text) {
190
+ return String(text || "")
191
+ .toLowerCase()
192
+ .replace(/[`"'\\]/g, " ")
193
+ .replace(/https?:\/\/\S+/g, " ")
194
+ .replace(/[^a-z0-9\s]/g, " ")
195
+ .replace(/\s+/g, " ")
196
+ .trim();
197
+ }
198
+ function hashToken(token) {
199
+ return createHash("sha1").update(token).digest("hex").slice(0, 12);
200
+ }
201
+ function buildTokenHashes(normalized) {
202
+ const words = normalized.split(" ").filter((w) => w.length >= 3);
203
+ const set = new Set();
204
+ for (let i = 0; i < words.length; i++) {
205
+ set.add(hashToken(words[i]));
206
+ if (i < words.length - 1)
207
+ set.add(hashToken(`${words[i]}_${words[i + 1]}`));
208
+ }
209
+ return Array.from(set).slice(0, 200);
210
+ }
211
+ function jaccardSimilarity(a, b) {
212
+ if (a.size === 0 || b.size === 0)
213
+ return 0;
214
+ let intersection = 0;
215
+ for (const item of a) {
216
+ if (b.has(item))
217
+ intersection++;
218
+ }
219
+ const union = a.size + b.size - intersection;
220
+ return union === 0 ? 0 : intersection / union;
221
+ }
222
+ function clamp(value, min, max) {
223
+ return Math.max(min, Math.min(max, value));
224
+ }
225
+ async function loadNoveltyCache(maxRecent) {
226
+ try {
227
+ const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
228
+ if (!Array.isArray(raw))
229
+ return [];
230
+ return raw
231
+ .filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
232
+ .slice(0, maxRecent);
233
+ }
234
+ catch {
235
+ return [];
236
+ }
237
+ }
238
+ async function saveNoveltyCache(entries) {
239
+ await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
240
+ await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
241
+ }
242
+ function inspectNovelty(content, entries, threshold) {
243
+ const normalized = normalizeMemoryText(content);
244
+ if (normalized.length < MIN_NOVELTY_CHARS) {
245
+ return { allow: true, contentHash: null, tokenHashes: [] };
246
+ }
247
+ const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
248
+ if (entries.some((entry) => entry.hash === contentHash)) {
249
+ return { allow: false, contentHash, tokenHashes: [], reason: "exact duplicate" };
250
+ }
251
+ const tokenHashes = buildTokenHashes(normalized);
252
+ const currentSet = new Set(tokenHashes);
253
+ for (const entry of entries) {
254
+ const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
255
+ if (score >= threshold) {
256
+ return {
257
+ allow: false,
258
+ contentHash,
259
+ tokenHashes,
260
+ reason: `near duplicate (similarity ${score.toFixed(2)})`,
261
+ };
262
+ }
263
+ }
264
+ return { allow: true, contentHash, tokenHashes };
265
+ }
266
+ async function createNoveltyGate(config) {
267
+ const thresholdRaw = Number(config.openclawAutoMemoryNoveltyThreshold);
268
+ const maxRecentRaw = Number(config.openclawAutoMemoryMaxRecent);
269
+ const threshold = Number.isFinite(thresholdRaw)
270
+ ? clamp(thresholdRaw, 0.6, 0.99)
271
+ : DEFAULT_NOVELTY_THRESHOLD;
272
+ const maxRecent = Number.isFinite(maxRecentRaw)
273
+ ? Math.floor(clamp(maxRecentRaw, 50, 1000))
274
+ : DEFAULT_MAX_RECENT;
275
+ const enabled = isAutoMemoryDedupeEnabled(config);
276
+ const entries = enabled ? await loadNoveltyCache(maxRecent) : [];
277
+ let dirty = false;
278
+ return {
279
+ inspect(content) {
280
+ if (!enabled)
281
+ return { allow: true, contentHash: null, tokenHashes: [] };
282
+ return inspectNovelty(content, entries, threshold);
283
+ },
284
+ remember(memory, novelty) {
285
+ if (!enabled || !novelty.contentHash || novelty.tokenHashes.length === 0)
286
+ return;
287
+ entries.unshift({
288
+ hash: novelty.contentHash,
289
+ tokenHashes: novelty.tokenHashes,
290
+ title: String(memory.title || "").slice(0, 120),
291
+ category: String(memory.category || "note"),
292
+ createdAt: new Date().toISOString(),
293
+ });
294
+ if (entries.length > maxRecent)
295
+ entries.length = maxRecent;
296
+ dirty = true;
297
+ },
298
+ async flush() {
299
+ if (!enabled || !dirty)
300
+ return;
301
+ await saveNoveltyCache(entries);
302
+ },
303
+ };
304
+ }
178
305
  // ==================== HOOK HANDLERS ====================
179
306
  // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
180
307
  const SKIP_PATTERNS = [
@@ -225,25 +352,38 @@ function handleLlmOutput(event, ctx) {
225
352
  // Fire and forget
226
353
  (async () => {
227
354
  try {
355
+ const config = await loadConfig();
356
+ if (!isAutoMemoryEnabled(config))
357
+ return;
228
358
  const texts = event.assistantTexts.filter(t => t && t.length >= 30);
229
359
  if (!texts.length)
230
360
  return;
231
361
  const memories = extractMemories(texts);
232
362
  if (!memories.length)
233
363
  return;
364
+ const noveltyGate = await createNoveltyGate(config);
234
365
  let saved = 0;
366
+ let skipped = 0;
235
367
  for (const mem of memories) {
368
+ const novelty = noveltyGate.inspect(mem.content);
369
+ if (!novelty.allow) {
370
+ skipped++;
371
+ continue;
372
+ }
236
373
  const r = await callCortex("remember", {
237
374
  title: mem.title, content: mem.content, category: mem.category,
238
375
  project: ctx.agentId || "openclaw", scope: "global",
239
376
  importance: "normal",
240
377
  });
241
- if (r)
378
+ if (r) {
242
379
  saved++;
380
+ noveltyGate.remember(mem, novelty);
381
+ }
243
382
  }
383
+ await noveltyGate.flush();
244
384
  if (saved) {
245
- console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output`);
246
- auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, ts: new Date().toISOString() });
385
+ console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
386
+ auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
247
387
  }
248
388
  }
249
389
  catch (e) {
@@ -255,7 +395,7 @@ function handleLlmOutput(event, ctx) {
255
395
  export default {
256
396
  id: "shieldcortex-realtime",
257
397
  name: "ShieldCortex Real-time Scanner",
258
- description: "Real-time defence scanning on LLM inputs and memory extraction from outputs",
398
+ description: "Real-time defence scanning on LLM inputs with optional memory extraction from outputs",
259
399
  version: "__SHIELDCORTEX_VERSION__",
260
400
  register(api) {
261
401
  api.on("llm_input", handleLlmInput);