shieldcortex 2.16.2 → 2.17.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.
Files changed (96) hide show
  1. package/README.md +47 -2
  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/hooks/openclaw/cortex-memory/HOOK.md +32 -2
  87. package/hooks/openclaw/cortex-memory/handler.ts +213 -10
  88. package/package.json +9 -1
  89. package/plugins/openclaw/README.md +35 -1
  90. package/plugins/openclaw/dist/index.js +145 -5
  91. package/plugins/openclaw/index.ts +183 -6
  92. package/scripts/postinstall.mjs +34 -0
  93. package/dashboard/.next/standalone/dashboard/.next/static/chunks/bccda52164e63171.css +0 -3
  94. /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → rafRHTrrEzsWtJlg9d1Sf}/_buildManifest.js +0 -0
  95. /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → rafRHTrrEzsWtJlg9d1Sf}/_clientMiddlewareManifest.json +0 -0
  96. /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → rafRHTrrEzsWtJlg9d1Sf}/_ssgManifest.js +0 -0
@@ -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,40 @@ 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
+
42
76
  ## Cloud Sync (optional)
43
77
 
44
78
  Add your API key to `~/.shieldcortex/config.json`:
@@ -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);
@@ -2,10 +2,11 @@
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
 
8
8
  import { execFile } from "node:child_process";
9
+ import { createHash } from "node:crypto";
9
10
  import fs from "node:fs/promises";
10
11
  import { readFileSync } from "node:fs";
11
12
  import path from "node:path";
@@ -34,7 +35,15 @@ type PluginApi = {
34
35
 
35
36
  // ==================== CONFIG ====================
36
37
 
37
- interface SCConfig { cloudApiKey?: string; cloudEndpoint?: string; binaryPath?: string }
38
+ interface SCConfig {
39
+ cloudApiKey?: string;
40
+ cloudEndpoint?: string;
41
+ binaryPath?: string;
42
+ openclawAutoMemory?: boolean;
43
+ openclawAutoMemoryDedupe?: boolean;
44
+ openclawAutoMemoryNoveltyThreshold?: number;
45
+ openclawAutoMemoryMaxRecent?: number;
46
+ }
38
47
  let _config: SCConfig | null = null;
39
48
  let _version = "0.0.0";
40
49
  try {
@@ -50,6 +59,14 @@ async function loadConfig(): Promise<SCConfig> {
50
59
  return _config!;
51
60
  }
52
61
 
62
+ function isAutoMemoryEnabled(config: SCConfig): boolean {
63
+ return config.openclawAutoMemory === true;
64
+ }
65
+
66
+ function isAutoMemoryDedupeEnabled(config: SCConfig): boolean {
67
+ return config.openclawAutoMemoryDedupe !== false;
68
+ }
69
+
53
70
  // ==================== SERVER CMD ====================
54
71
 
55
72
  let _serverCmd: string | null = null;
@@ -133,6 +150,10 @@ function extractUserContent(msgs: unknown[]): string[] {
133
150
  }
134
151
 
135
152
  const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
153
+ const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
154
+ const DEFAULT_NOVELTY_THRESHOLD = 0.88;
155
+ const DEFAULT_MAX_RECENT = 300;
156
+ const MIN_NOVELTY_CHARS = 40;
136
157
 
137
158
  async function auditLog(entry: Record<string, unknown>) {
138
159
  try {
@@ -157,6 +178,147 @@ async function cloudSync(threat: Record<string, unknown>) {
157
178
  } catch {}
158
179
  }
159
180
 
181
+ type NoveltyEntry = {
182
+ hash: string;
183
+ tokenHashes: string[];
184
+ title: string;
185
+ category: string;
186
+ createdAt: string;
187
+ };
188
+
189
+ function normalizeMemoryText(text: string): string {
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
+
199
+ function hashToken(token: string): string {
200
+ return createHash("sha1").update(token).digest("hex").slice(0, 12);
201
+ }
202
+
203
+ function buildTokenHashes(normalized: string): string[] {
204
+ const words = normalized.split(" ").filter((w) => w.length >= 3);
205
+ const set = new Set<string>();
206
+
207
+ for (let i = 0; i < words.length; i++) {
208
+ set.add(hashToken(words[i]));
209
+ if (i < words.length - 1) set.add(hashToken(`${words[i]}_${words[i + 1]}`));
210
+ }
211
+
212
+ return Array.from(set).slice(0, 200);
213
+ }
214
+
215
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
216
+ if (a.size === 0 || b.size === 0) return 0;
217
+ let intersection = 0;
218
+ for (const item of a) {
219
+ if (b.has(item)) intersection++;
220
+ }
221
+ const union = a.size + b.size - intersection;
222
+ return union === 0 ? 0 : intersection / union;
223
+ }
224
+
225
+ function clamp(value: number, min: number, max: number): number {
226
+ return Math.max(min, Math.min(max, value));
227
+ }
228
+
229
+ async function loadNoveltyCache(maxRecent: number): Promise<NoveltyEntry[]> {
230
+ try {
231
+ const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
232
+ if (!Array.isArray(raw)) return [];
233
+ return raw
234
+ .filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
235
+ .slice(0, maxRecent) as NoveltyEntry[];
236
+ } catch {
237
+ return [];
238
+ }
239
+ }
240
+
241
+ async function saveNoveltyCache(entries: NoveltyEntry[]): Promise<void> {
242
+ await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
243
+ await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
244
+ }
245
+
246
+ function inspectNovelty(content: string, entries: NoveltyEntry[], threshold: number): {
247
+ allow: boolean;
248
+ contentHash: string | null;
249
+ tokenHashes: string[];
250
+ reason?: string;
251
+ } {
252
+ const normalized = normalizeMemoryText(content);
253
+ if (normalized.length < MIN_NOVELTY_CHARS) {
254
+ return { allow: true, contentHash: null, tokenHashes: [] };
255
+ }
256
+
257
+ const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
258
+ if (entries.some((entry) => entry.hash === contentHash)) {
259
+ return { allow: false, contentHash, tokenHashes: [], reason: "exact duplicate" };
260
+ }
261
+
262
+ const tokenHashes = buildTokenHashes(normalized);
263
+ const currentSet = new Set(tokenHashes);
264
+
265
+ for (const entry of entries) {
266
+ const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
267
+ if (score >= threshold) {
268
+ return {
269
+ allow: false,
270
+ contentHash,
271
+ tokenHashes,
272
+ reason: `near duplicate (similarity ${score.toFixed(2)})`,
273
+ };
274
+ }
275
+ }
276
+
277
+ return { allow: true, contentHash, tokenHashes };
278
+ }
279
+
280
+ async function createNoveltyGate(config: SCConfig): Promise<{
281
+ inspect: (content: string) => { allow: boolean; contentHash: string | null; tokenHashes: string[]; reason?: string };
282
+ remember: (memory: { title: string; category: string }, novelty: { contentHash: string | null; tokenHashes: string[] }) => void;
283
+ flush: () => Promise<void>;
284
+ }> {
285
+ const thresholdRaw = Number(config.openclawAutoMemoryNoveltyThreshold);
286
+ const maxRecentRaw = Number(config.openclawAutoMemoryMaxRecent);
287
+ const threshold = Number.isFinite(thresholdRaw)
288
+ ? clamp(thresholdRaw, 0.6, 0.99)
289
+ : DEFAULT_NOVELTY_THRESHOLD;
290
+ const maxRecent = Number.isFinite(maxRecentRaw)
291
+ ? Math.floor(clamp(maxRecentRaw, 50, 1000))
292
+ : DEFAULT_MAX_RECENT;
293
+
294
+ const enabled = isAutoMemoryDedupeEnabled(config);
295
+ const entries = enabled ? await loadNoveltyCache(maxRecent) : [];
296
+ let dirty = false;
297
+
298
+ return {
299
+ inspect(content: string) {
300
+ if (!enabled) return { allow: true, contentHash: null, tokenHashes: [] };
301
+ return inspectNovelty(content, entries, threshold);
302
+ },
303
+ remember(memory, novelty) {
304
+ if (!enabled || !novelty.contentHash || novelty.tokenHashes.length === 0) return;
305
+ entries.unshift({
306
+ hash: novelty.contentHash,
307
+ tokenHashes: novelty.tokenHashes,
308
+ title: String(memory.title || "").slice(0, 120),
309
+ category: String(memory.category || "note"),
310
+ createdAt: new Date().toISOString(),
311
+ });
312
+ if (entries.length > maxRecent) entries.length = maxRecent;
313
+ dirty = true;
314
+ },
315
+ async flush() {
316
+ if (!enabled || !dirty) return;
317
+ await saveNoveltyCache(entries);
318
+ },
319
+ };
320
+ }
321
+
160
322
  // ==================== HOOK HANDLERS ====================
161
323
 
162
324
  // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
@@ -208,23 +370,38 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
208
370
  // Fire and forget
209
371
  (async () => {
210
372
  try {
373
+ const config = await loadConfig();
374
+ if (!isAutoMemoryEnabled(config)) return;
375
+
211
376
  const texts = event.assistantTexts.filter(t => t && t.length >= 30);
212
377
  if (!texts.length) return;
213
378
  const memories = extractMemories(texts);
214
379
  if (!memories.length) return;
215
380
 
381
+ const noveltyGate = await createNoveltyGate(config);
216
382
  let saved = 0;
383
+ let skipped = 0;
217
384
  for (const mem of memories) {
385
+ const novelty = noveltyGate.inspect(mem.content);
386
+ if (!novelty.allow) {
387
+ skipped++;
388
+ continue;
389
+ }
390
+
218
391
  const r = await callCortex("remember", {
219
392
  title: mem.title, content: mem.content, category: mem.category,
220
393
  project: ctx.agentId || "openclaw", scope: "global",
221
394
  importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
222
395
  });
223
- if (r) saved++;
396
+ if (r) {
397
+ saved++;
398
+ noveltyGate.remember(mem, novelty);
399
+ }
224
400
  }
401
+ await noveltyGate.flush();
225
402
  if (saved) {
226
- console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output`);
227
- auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, ts: new Date().toISOString() });
403
+ console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
404
+ auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
228
405
  }
229
406
  } catch (e) {
230
407
  console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
@@ -237,7 +414,7 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
237
414
  export default {
238
415
  id: "shieldcortex-realtime",
239
416
  name: "ShieldCortex Real-time Scanner",
240
- description: "Real-time defence scanning on LLM inputs and memory extraction from outputs",
417
+ description: "Real-time defence scanning on LLM inputs with optional memory extraction from outputs",
241
418
  version: _version,
242
419
 
243
420
  register(api: PluginApi) {
@@ -3,12 +3,46 @@
3
3
  * Postinstall script - prints setup instructions after global install.
4
4
  * Does NOT auto-run setup (can fail in CI, user might not have Claude Code).
5
5
  */
6
+ import { existsSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { homedir } from 'os';
9
+ import { spawnSync } from 'child_process';
10
+ import { fileURLToPath } from 'url';
6
11
 
7
12
  // Only show message for global installs (not local dev or CI)
8
13
  const isGlobal = process.env.npm_config_global === 'true';
9
14
  const isCI = process.env.CI === 'true' || process.env.CONTINUOUS_INTEGRATION === 'true';
15
+ const skipAutoOpenClaw = process.env.SHIELDCORTEX_SKIP_AUTO_OPENCLAW === '1';
16
+
17
+ function shouldRefreshOpenClaw() {
18
+ const home = homedir();
19
+ const openclawDir = join(home, '.openclaw');
20
+ const knownHook = join(openclawDir, 'hooks', 'cortex-memory');
21
+ const knownPlugin = join(openclawDir, 'extensions', 'shieldcortex-realtime');
22
+ return existsSync(openclawDir) || existsSync(knownHook) || existsSync(knownPlugin);
23
+ }
24
+
25
+ function refreshOpenClawInstall() {
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = dirname(__filename);
28
+ const cliPath = join(__dirname, '..', 'dist', 'index.js');
29
+
30
+ if (!existsSync(cliPath)) return;
31
+ const result = spawnSync(process.execPath, [cliPath, 'openclaw', 'install'], {
32
+ stdio: 'inherit',
33
+ env: process.env,
34
+ });
35
+ if (result.status !== 0) {
36
+ console.warn('[shieldcortex] OpenClaw auto-refresh skipped (non-fatal).');
37
+ }
38
+ }
10
39
 
11
40
  if (isGlobal && !isCI) {
41
+ if (!skipAutoOpenClaw && shouldRefreshOpenClaw()) {
42
+ console.log('\n[shieldcortex] OpenClaw detected. Refreshing hook/plugin to latest version...');
43
+ refreshOpenClawInstall();
44
+ }
45
+
12
46
  console.log('');
13
47
  console.log('\x1b[36m╭───────────────────────────────────────────────────────╮\x1b[0m');
14
48
  console.log('\x1b[36m│\x1b[0m \x1b[1mShieldCortex installed!\x1b[0m \x1b[36m│\x1b[0m');