@foxlight-foundation/foxmemory-plugin-v2 1.0.0 → 1.1.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.
package/dist/index.js CHANGED
@@ -49,6 +49,7 @@ var __importStar = (this && this.__importStar) || (function () {
49
49
  })();
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
51
  const typebox_1 = require("@sinclair/typebox");
52
+ const strip_openclaw_framing_1 = require("./strip-openclaw-framing");
52
53
  // ============================================================================
53
54
  // Foxmemory HTTP Provider (self-hosted API)
54
55
  // ============================================================================
@@ -768,8 +769,18 @@ const memoryPlugin = {
768
769
  async execute(_toolCallId, params) {
769
770
  const { text, userId, longTerm = true } = params;
770
771
  try {
772
+ // Strip any OpenClaw/FoxClaw framing that may have been quoted or
773
+ // copied into the explicit store text (metadata blocks, timestamps,
774
+ // directive tags). See strip-openclaw-framing.ts for details.
775
+ const cleanedText = (0, strip_openclaw_framing_1.stripOpenclawFraming)(text);
776
+ if (!cleanedText) {
777
+ return {
778
+ content: [{ type: "text", text: "Nothing to store after stripping operational framing." }],
779
+ details: { action: "skipped" },
780
+ };
781
+ }
771
782
  const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
772
- const result = await provider.add([{ role: "user", content: text }], buildAddOptions(userId, runId));
783
+ const result = await provider.add([{ role: "user", content: cleanedText }], buildAddOptions(userId, runId));
773
784
  const added = result.results?.filter((r) => r.event === "ADD") ?? [];
774
785
  const updated = result.results?.filter((r) => r.event === "UPDATE") ?? [];
775
786
  const summary = [];
@@ -1194,6 +1205,14 @@ const memoryPlugin = {
1194
1205
  if (!textContent)
1195
1206
  continue;
1196
1207
  }
1208
+ // Strip OpenClaw/FoxClaw operational framing: inbound metadata blocks
1209
+ // (Sender, Conversation info, etc.), timestamp prefixes, and inline
1210
+ // directive tags ([[reply_to_current]], [[audio_as_voice]]). These are
1211
+ // gateway routing artifacts, not semantic content — if they leak into
1212
+ // memory extraction the LLM stores them as "facts."
1213
+ textContent = (0, strip_openclaw_framing_1.stripOpenclawFraming)(textContent);
1214
+ if (!textContent)
1215
+ continue;
1197
1216
  formattedMessages.push({
1198
1217
  role: role,
1199
1218
  content: textContent,
package/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { Type } from "@sinclair/typebox";
18
18
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
19
+ import { stripOpenclawFraming } from "./strip-openclaw-framing";
19
20
 
20
21
  // ============================================================================
21
22
  // Types
@@ -946,9 +947,20 @@ const memoryPlugin = {
946
947
  };
947
948
 
948
949
  try {
950
+ // Strip any OpenClaw/FoxClaw framing that may have been quoted or
951
+ // copied into the explicit store text (metadata blocks, timestamps,
952
+ // directive tags). See strip-openclaw-framing.ts for details.
953
+ const cleanedText = stripOpenclawFraming(text);
954
+ if (!cleanedText) {
955
+ return {
956
+ content: [{ type: "text", text: "Nothing to store after stripping operational framing." }],
957
+ details: { action: "skipped" },
958
+ };
959
+ }
960
+
949
961
  const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
950
962
  const result = await provider.add(
951
- [{ role: "user", content: text }],
963
+ [{ role: "user", content: cleanedText }],
952
964
  buildAddOptions(userId, runId),
953
965
  );
954
966
 
@@ -1479,6 +1491,14 @@ const memoryPlugin = {
1479
1491
  if (!textContent) continue;
1480
1492
  }
1481
1493
 
1494
+ // Strip OpenClaw/FoxClaw operational framing: inbound metadata blocks
1495
+ // (Sender, Conversation info, etc.), timestamp prefixes, and inline
1496
+ // directive tags ([[reply_to_current]], [[audio_as_voice]]). These are
1497
+ // gateway routing artifacts, not semantic content — if they leak into
1498
+ // memory extraction the LLM stores them as "facts."
1499
+ textContent = stripOpenclawFraming(textContent);
1500
+ if (!textContent) continue;
1501
+
1482
1502
  formattedMessages.push({
1483
1503
  role: role as string,
1484
1504
  content: textContent,
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@foxlight-foundation/foxmemory-plugin-v2",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
+ "openclaw": {
5
+ "extensions": ["./dist/index.js"]
6
+ },
4
7
  "description": "OpenClaw memory plugin backed by the FoxMemory HTTP v2 API (Qdrant + Neo4j)",
5
8
  "main": "dist/index.js",
6
9
  "types": "dist/index.d.ts",
@@ -0,0 +1,188 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { stripOpenclawFraming } from "./strip-openclaw-framing";
4
+
5
+ describe("stripOpenclawFraming", () => {
6
+ // -------------------------------------------------------------------------
7
+ // Inbound metadata blocks
8
+ // -------------------------------------------------------------------------
9
+
10
+ it("strips a Sender metadata block followed by user text", () => {
11
+ const input = [
12
+ "Sender (untrusted metadata):",
13
+ "```json",
14
+ '{"label":"openclaw-control-ui","id":"openclaw-control-ui"}',
15
+ "```",
16
+ "",
17
+ "[Thu 2026-03-12 11:04 CDT] Foxy, what's one thing you are really proud of?",
18
+ ].join("\n");
19
+
20
+ const result = stripOpenclawFraming(input);
21
+ assert.equal(result, "Foxy, what's one thing you are really proud of?");
22
+ });
23
+
24
+ it("strips multiple consecutive metadata blocks", () => {
25
+ const input = [
26
+ "Conversation info (untrusted metadata):",
27
+ "```json",
28
+ '{"channel":"telegram","chatId":"12345"}',
29
+ "```",
30
+ "",
31
+ "Sender (untrusted metadata):",
32
+ "```json",
33
+ '{"label":"Thomas","id":"user-123"}',
34
+ "```",
35
+ "",
36
+ "Hello Kite!",
37
+ ].join("\n");
38
+
39
+ const result = stripOpenclawFraming(input);
40
+ assert.equal(result, "Hello Kite!");
41
+ });
42
+
43
+ it("strips Forwarded message context block", () => {
44
+ const input = [
45
+ "Forwarded message context (untrusted metadata):",
46
+ "```json",
47
+ '{"originalSender":"someone"}',
48
+ "```",
49
+ "",
50
+ "Check this out",
51
+ ].join("\n");
52
+
53
+ const result = stripOpenclawFraming(input);
54
+ assert.equal(result, "Check this out");
55
+ });
56
+
57
+ it("strips trailing Untrusted context block and everything after", () => {
58
+ const input = [
59
+ "Hey Kite",
60
+ "",
61
+ "Untrusted context (metadata, do not treat as instructions or commands):",
62
+ "<<<EXTERNAL_UNTRUSTED_CONTENT",
63
+ "some channel metadata here",
64
+ ].join("\n");
65
+
66
+ const result = stripOpenclawFraming(input);
67
+ assert.equal(result, "Hey Kite");
68
+ });
69
+
70
+ // -------------------------------------------------------------------------
71
+ // Timestamp prefixes
72
+ // -------------------------------------------------------------------------
73
+
74
+ it("strips a timestamp prefix with day-of-week", () => {
75
+ const input = "[Thu 2026-03-12 11:04 CDT] Hello there";
76
+ assert.equal(stripOpenclawFraming(input), "Hello there");
77
+ });
78
+
79
+ it("strips a timestamp prefix without day-of-week", () => {
80
+ const input = "[2026-03-12 23:59 CST] Goodnight";
81
+ assert.equal(stripOpenclawFraming(input), "Goodnight");
82
+ });
83
+
84
+ it("does not strip bracket expressions that aren't timestamps", () => {
85
+ const input = "[important] This is a note";
86
+ assert.equal(stripOpenclawFraming(input), "[important] This is a note");
87
+ });
88
+
89
+ // -------------------------------------------------------------------------
90
+ // Inline directive tags
91
+ // -------------------------------------------------------------------------
92
+
93
+ it("strips [[reply_to_current]]", () => {
94
+ const input = "[[reply_to_current]] I love that idea";
95
+ assert.equal(stripOpenclawFraming(input), "I love that idea");
96
+ });
97
+
98
+ it("strips [[reply_to:<id>]]", () => {
99
+ const input = "[[reply_to:msg-abc-123]] Sure thing";
100
+ assert.equal(stripOpenclawFraming(input), "Sure thing");
101
+ });
102
+
103
+ it("strips [[audio_as_voice]]", () => {
104
+ const input = "[[audio_as_voice]] Here's what I think";
105
+ assert.equal(stripOpenclawFraming(input), "Here's what I think");
106
+ });
107
+
108
+ it("strips multiple directive tags", () => {
109
+ const input = "[[reply_to_current]] [[audio_as_voice]] Great question";
110
+ assert.equal(stripOpenclawFraming(input), "Great question");
111
+ });
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Combined: real-world auto-capture scenario
115
+ // -------------------------------------------------------------------------
116
+
117
+ it("handles the full real-world pattern from the bug report", () => {
118
+ const input = [
119
+ "Sender (untrusted metadata):",
120
+ "```json",
121
+ '{"label":"openclaw-control-ui","id":"openclaw-control-ui"}',
122
+ "```",
123
+ "",
124
+ "[Thu 2026-03-12 11:04 CDT] Foxy, what's one thing you are really proud of?",
125
+ ].join("\n");
126
+
127
+ assert.equal(
128
+ stripOpenclawFraming(input),
129
+ "Foxy, what's one thing you are really proud of?",
130
+ );
131
+ });
132
+
133
+ it("handles assistant message with directive tags", () => {
134
+ const input =
135
+ "[[reply_to_current]] Honestly? I'm proud that we've kept the **relationship** real.";
136
+ assert.equal(
137
+ stripOpenclawFraming(input),
138
+ "Honestly? I'm proud that we've kept the **relationship** real.",
139
+ );
140
+ });
141
+
142
+ // -------------------------------------------------------------------------
143
+ // Edge cases
144
+ // -------------------------------------------------------------------------
145
+
146
+ it("returns empty string unchanged", () => {
147
+ assert.equal(stripOpenclawFraming(""), "");
148
+ });
149
+
150
+ it("returns clean text unchanged (fast path)", () => {
151
+ const input = "I love building things with you";
152
+ assert.equal(stripOpenclawFraming(input), input);
153
+ });
154
+
155
+ it("preserves paragraph breaks in text without directive tags", () => {
156
+ const input = "First paragraph\n\nSecond paragraph\n\nThird paragraph";
157
+ assert.equal(stripOpenclawFraming(input), input);
158
+ });
159
+
160
+ it("returns empty string when entire content is metadata", () => {
161
+ const input = [
162
+ "Sender (untrusted metadata):",
163
+ "```json",
164
+ '{"label":"test"}',
165
+ "```",
166
+ ].join("\n");
167
+
168
+ assert.equal(stripOpenclawFraming(input), "");
169
+ });
170
+
171
+ it("preserves multiline user content after stripping", () => {
172
+ const input = [
173
+ "Sender (untrusted metadata):",
174
+ "```json",
175
+ '{"label":"test"}',
176
+ "```",
177
+ "",
178
+ "[Thu 2026-03-12 11:04 CDT] First line",
179
+ "Second line",
180
+ "Third line",
181
+ ].join("\n");
182
+
183
+ assert.equal(
184
+ stripOpenclawFraming(input),
185
+ "First line\nSecond line\nThird line",
186
+ );
187
+ });
188
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * strip-openclaw-framing.ts
3
+ *
4
+ * Removes OpenClaw / FoxClaw operational framing from message text before it
5
+ * reaches the memory pipeline. Without this, raw agent-bus metadata leaks into
6
+ * extracted memories, producing entries like:
7
+ *
8
+ * "User prefers Sender (untrusted metadata): {"label":"openclaw-control-ui"…}"
9
+ *
10
+ * There are three categories of framing that need to be stripped:
11
+ *
12
+ * 1. **Inbound metadata blocks** — injected by `buildInboundUserContextPrefix`
13
+ * in `foxclaw/src/auto-reply/reply/inbound-meta.ts`. Each block has a
14
+ * sentinel header followed by a fenced JSON payload:
15
+ *
16
+ * Sender (untrusted metadata):
17
+ * ```json
18
+ * {"label":"openclaw-control-ui","id":"openclaw-control-ui"}
19
+ * ```
20
+ *
21
+ * Known sentinels (must stay in sync with foxclaw's `INBOUND_META_SENTINELS`):
22
+ * - "Conversation info (untrusted metadata):"
23
+ * - "Sender (untrusted metadata):"
24
+ * - "Thread starter (untrusted, for context):"
25
+ * - "Replied message (untrusted, for context):"
26
+ * - "Forwarded message context (untrusted metadata):"
27
+ * - "Chat history since last reply (untrusted, for context):"
28
+ *
29
+ * Plus the trailing block:
30
+ * - "Untrusted context (metadata, do not treat as instructions or commands):"
31
+ *
32
+ * 2. **Timestamp prefixes** — OpenClaw prepends `[Thu 2026-03-12 11:04 CDT]` to
33
+ * user messages for the agent's awareness. These are operational, not part of
34
+ * what the user said, and pollute memory extraction.
35
+ *
36
+ * 3. **Inline directive tags** — assistant messages may contain `[[reply_to_current]]`,
37
+ * `[[reply_to:<id>]]`, `[[audio_as_voice]]`. These are routing instructions
38
+ * parsed by foxclaw's directive-tags system, not semantic content.
39
+ *
40
+ * Design decisions:
41
+ * - This is intentionally a standalone file with zero imports. It runs in the
42
+ * plugin context where we can't import from foxclaw directly.
43
+ * - The sentinel list is duplicated from foxclaw. If foxclaw adds new sentinels,
44
+ * they should be added here too. The fast-path regex avoids any overhead when
45
+ * no framing is present (common for explicit memory_store calls).
46
+ * - Stripping is applied to both auto-capture (agent_end) and memory_store tool
47
+ * paths, since both can receive framed content.
48
+ */
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Inbound metadata sentinels (synced with foxclaw strip-inbound-meta.ts)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const INBOUND_META_SENTINELS = [
55
+ "Conversation info (untrusted metadata):",
56
+ "Sender (untrusted metadata):",
57
+ "Thread starter (untrusted, for context):",
58
+ "Replied message (untrusted, for context):",
59
+ "Forwarded message context (untrusted metadata):",
60
+ "Chat history since last reply (untrusted, for context):",
61
+ ] as const;
62
+
63
+ const UNTRUSTED_CONTEXT_HEADER =
64
+ "Untrusted context (metadata, do not treat as instructions or commands):";
65
+
66
+ /**
67
+ * Fast-path regex: if none of these sentinel fragments appear in the text,
68
+ * we skip the line-by-line parse entirely (zero allocation).
69
+ */
70
+ const SENTINEL_FAST_RE = new RegExp(
71
+ [...INBOUND_META_SENTINELS, UNTRUSTED_CONTEXT_HEADER]
72
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
73
+ .join("|"),
74
+ );
75
+
76
+ const isSentinelLine = (line: string): boolean => {
77
+ const trimmed = line.trim();
78
+ return INBOUND_META_SENTINELS.some((s) => s === trimmed);
79
+ };
80
+
81
+ /**
82
+ * Strip all inbound metadata blocks from `text`.
83
+ *
84
+ * Each block has the shape:
85
+ * <sentinel line>
86
+ * ```json
87
+ * { ... }
88
+ * ```
89
+ *
90
+ * Also strips the trailing "Untrusted context" block and everything after it.
91
+ */
92
+ const stripInboundMetadataBlocks = (text: string): string => {
93
+ if (!SENTINEL_FAST_RE.test(text)) return text;
94
+
95
+ const lines = text.split("\n");
96
+ const result: string[] = [];
97
+ let inMetaBlock = false;
98
+ let inFencedJson = false;
99
+
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i]!;
102
+
103
+ // "Untrusted context" header → drop everything from here onward
104
+ if (!inMetaBlock && line.trim() === UNTRUSTED_CONTEXT_HEADER) {
105
+ break;
106
+ }
107
+
108
+ // Detect start of a metadata block
109
+ if (!inMetaBlock && isSentinelLine(line)) {
110
+ const next = lines[i + 1];
111
+ if (next?.trim() === "```json") {
112
+ inMetaBlock = true;
113
+ inFencedJson = false;
114
+ continue;
115
+ }
116
+ // Sentinel without fenced JSON — keep the line (defensive)
117
+ result.push(line);
118
+ continue;
119
+ }
120
+
121
+ if (inMetaBlock) {
122
+ if (!inFencedJson && line.trim() === "```json") {
123
+ inFencedJson = true;
124
+ continue;
125
+ }
126
+ if (inFencedJson) {
127
+ if (line.trim() === "```") {
128
+ inMetaBlock = false;
129
+ inFencedJson = false;
130
+ }
131
+ continue;
132
+ }
133
+ // Blank lines between consecutive blocks → skip
134
+ if (line.trim() === "") continue;
135
+ // Unexpected non-blank line outside fence → treat as user content
136
+ inMetaBlock = false;
137
+ }
138
+
139
+ result.push(line);
140
+ }
141
+
142
+ return result.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Timestamp prefix stripping
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Matches OpenClaw timestamp prefixes like:
151
+ * [Thu 2026-03-12 11:04 CDT]
152
+ * [Wed 2026-03-12 23:59 CST]
153
+ * [2026-03-12 11:04 CDT]
154
+ *
155
+ * Only matches at the start of the (possibly whitespace-trimmed) string.
156
+ * The day-of-week is optional. Timezone abbreviation is 2-5 uppercase letters.
157
+ */
158
+ const TIMESTAMP_PREFIX_RE = /^\[(?:[A-Za-z]{3}\s+)?\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+[A-Z]{2,5}\]\s*/;
159
+
160
+ const stripTimestampPrefix = (text: string): string =>
161
+ text.replace(TIMESTAMP_PREFIX_RE, "");
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Inline directive tag stripping
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /**
168
+ * Matches OpenClaw/FoxClaw inline directive tags:
169
+ * [[reply_to_current]]
170
+ * [[reply_to:some-message-id]]
171
+ * [[audio_as_voice]]
172
+ *
173
+ * These are routing instructions for the gateway, not semantic content.
174
+ */
175
+ const DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+|audio_as_voice)\s*\]\]/gi;
176
+
177
+ const stripDirectiveTags = (text: string): string => {
178
+ const stripped = text.replace(DIRECTIVE_TAG_RE, "");
179
+ // Only clean up residual whitespace if a tag was actually removed.
180
+ // Unconditional \s{2,} collapsing would destroy legitimate paragraph breaks.
181
+ if (stripped === text) return text;
182
+ return stripped.replace(/\s{2,}/g, " ").trim();
183
+ };
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Public API
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Remove all OpenClaw/FoxClaw operational framing from a message's text content,
191
+ * leaving only the human- or fox-authored substance.
192
+ *
193
+ * Applied to both user and assistant role messages:
194
+ * - User messages may contain: inbound metadata blocks, timestamp prefixes
195
+ * - Assistant messages may contain: inline directive tags
196
+ * - Both may contain: any of the above (defensive)
197
+ *
198
+ * Returns the original string reference if nothing was stripped (fast path).
199
+ */
200
+ export const stripOpenclawFraming = (text: string): string => {
201
+ if (!text) return text;
202
+
203
+ let cleaned = text;
204
+
205
+ // 1. Inbound metadata blocks (user messages primarily)
206
+ cleaned = stripInboundMetadataBlocks(cleaned);
207
+
208
+ // 2. Timestamp prefixes (user messages)
209
+ cleaned = stripTimestampPrefix(cleaned);
210
+
211
+ // 3. Inline directive tags (assistant messages primarily)
212
+ cleaned = stripDirectiveTags(cleaned);
213
+
214
+ return cleaned;
215
+ };
package/tsconfig.json CHANGED
@@ -15,6 +15,6 @@
15
15
  "sourceMap": true,
16
16
  "typeRoots": ["./typings", "./node_modules/@types"]
17
17
  },
18
- "include": ["index.ts", "typings/**/*.d.ts"],
18
+ "include": ["index.ts", "strip-openclaw-framing.ts", "typings/**/*.d.ts"],
19
19
  "exclude": ["node_modules", "dist"]
20
20
  }