@frontmcp/plugin-remember 1.7.1 → 1.8.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.
- package/esm/index.mjs +939 -748
- package/esm/package.json +4 -4
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +930 -739
- package/package.json +4 -4
- package/providers/remember-accessor.provider.d.ts +20 -4
- package/providers/remember-accessor.provider.d.ts.map +1 -1
- package/providers/remember-memory.provider.d.ts +7 -0
- package/providers/remember-memory.provider.d.ts.map +1 -1
- package/providers/remember-redis.provider.d.ts +8 -1
- package/providers/remember-redis.provider.d.ts.map +1 -1
- package/providers/remember-store.interface.d.ts +13 -0
- package/providers/remember-store.interface.d.ts.map +1 -1
- package/providers/remember-vercel-kv.provider.d.ts +7 -0
- package/providers/remember-vercel-kv.provider.d.ts.map +1 -1
- package/remember.crypto.d.ts +17 -6
- package/remember.crypto.d.ts.map +1 -1
- package/remember.errors.d.ts +12 -0
- package/remember.errors.d.ts.map +1 -0
- package/remember.legacy-purge.d.ts +45 -0
- package/remember.legacy-purge.d.ts.map +1 -0
- package/remember.plugin.d.ts.map +1 -1
- package/remember.types.d.ts +25 -0
- package/remember.types.d.ts.map +1 -1
package/index.js
CHANGED
|
@@ -46,6 +46,7 @@ __export(index_exports, {
|
|
|
46
46
|
RememberAccessor: () => RememberAccessor,
|
|
47
47
|
RememberAccessorToken: () => RememberAccessorToken,
|
|
48
48
|
RememberConfigToken: () => RememberConfigToken,
|
|
49
|
+
RememberIdentityError: () => RememberIdentityError,
|
|
49
50
|
RememberMemoryProvider: () => RememberMemoryProvider,
|
|
50
51
|
RememberPlugin: () => RememberPlugin,
|
|
51
52
|
RememberRedisProvider: () => RememberRedisProvider,
|
|
@@ -62,872 +63,1061 @@ module.exports = __toCommonJS(index_exports);
|
|
|
62
63
|
// plugins/plugin-remember/src/remember.plugin.ts
|
|
63
64
|
var import_sdk5 = require("@frontmcp/sdk");
|
|
64
65
|
|
|
65
|
-
// plugins/plugin-remember/src/remember.
|
|
66
|
-
var RememberStoreToken = /* @__PURE__ */ Symbol(
|
|
67
|
-
"plugin:remember:store"
|
|
68
|
-
);
|
|
69
|
-
var RememberConfigToken = /* @__PURE__ */ Symbol(
|
|
70
|
-
"plugin:remember:config"
|
|
71
|
-
);
|
|
72
|
-
var RememberAccessorToken = /* @__PURE__ */ Symbol(
|
|
73
|
-
"plugin:remember:accessor"
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
// plugins/plugin-remember/src/providers/remember-memory.provider.ts
|
|
66
|
+
// plugins/plugin-remember/src/providers/remember-accessor.provider.ts
|
|
77
67
|
var import_sdk = require("@frontmcp/sdk");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
68
|
+
|
|
69
|
+
// plugins/plugin-remember/src/remember.crypto.ts
|
|
70
|
+
var import_utils2 = require("@frontmcp/utils");
|
|
71
|
+
|
|
72
|
+
// plugins/plugin-remember/src/remember.secret-persistence.ts
|
|
73
|
+
var path = __toESM(require("path"));
|
|
74
|
+
var import_lazy_zod = require("@frontmcp/lazy-zod");
|
|
75
|
+
var import_utils = require("@frontmcp/utils");
|
|
76
|
+
var DEFAULT_SECRET_FILE_PATH = ".frontmcp/remember-secret.json";
|
|
77
|
+
var SECRET_BYTES = 32;
|
|
78
|
+
var rememberSecretDataSchema = import_lazy_zod.z.object({
|
|
79
|
+
secret: import_lazy_zod.z.string().min(32),
|
|
80
|
+
// base64url of 32 bytes is ~43 chars
|
|
81
|
+
createdAt: import_lazy_zod.z.number().positive().int(),
|
|
82
|
+
version: import_lazy_zod.z.literal(1)
|
|
83
|
+
});
|
|
84
|
+
function validateSecretData(data) {
|
|
85
|
+
const result = rememberSecretDataSchema.safeParse(data);
|
|
86
|
+
if (!result.success) {
|
|
87
|
+
return { valid: false, error: result.error.issues[0]?.message ?? "Invalid secret structure" };
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
async setValue(key, value, ttlSeconds) {
|
|
91
|
-
const strValue = JSON.stringify(value);
|
|
92
|
-
const existing = this.memory.get(key);
|
|
93
|
-
if (existing?.timeout) clearTimeout(existing.timeout);
|
|
94
|
-
const entry = { value: strValue };
|
|
95
|
-
if (ttlSeconds && ttlSeconds > 0) {
|
|
96
|
-
const ttlMs = ttlSeconds * 1e3;
|
|
97
|
-
entry.expiresAt = Date.now() + ttlMs;
|
|
98
|
-
if (ttlMs <= MAX_TIMEOUT_MS) {
|
|
99
|
-
entry.timeout = setTimeout(() => {
|
|
100
|
-
const e = this.memory.get(key);
|
|
101
|
-
if (e && e.expiresAt && e.expiresAt <= Date.now()) {
|
|
102
|
-
this.memory.delete(key);
|
|
103
|
-
}
|
|
104
|
-
}, ttlMs);
|
|
105
|
-
entry.timeout.unref?.();
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
this.memory.set(key, entry);
|
|
89
|
+
const parsed = result.data;
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
if (parsed.createdAt > now + 6e4) {
|
|
92
|
+
return { valid: false, error: "createdAt is in the future" };
|
|
109
93
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
94
|
+
const hundredYearsMs = 100 * 365 * 24 * 60 * 60 * 1e3;
|
|
95
|
+
if (parsed.createdAt < now - hundredYearsMs) {
|
|
96
|
+
return { valid: false, error: "createdAt is too old" };
|
|
97
|
+
}
|
|
98
|
+
return { valid: true };
|
|
99
|
+
}
|
|
100
|
+
function isSecretPersistenceEnabled(options) {
|
|
101
|
+
const isProduction = process.env["NODE_ENV"] === "production";
|
|
102
|
+
if (isProduction) {
|
|
103
|
+
return options?.forceEnable === true;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
function resolveSecretPath(options) {
|
|
108
|
+
const secretPath = options?.secretPath ?? DEFAULT_SECRET_FILE_PATH;
|
|
109
|
+
if (path.isAbsolute(secretPath)) {
|
|
110
|
+
return secretPath;
|
|
111
|
+
}
|
|
112
|
+
return path.resolve(process.cwd(), secretPath);
|
|
113
|
+
}
|
|
114
|
+
async function loadRememberSecret(options) {
|
|
115
|
+
if (!isSecretPersistenceEnabled(options)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const secretPath = resolveSecretPath(options);
|
|
119
|
+
try {
|
|
120
|
+
const content = await (0, import_utils.readFile)(secretPath, "utf8");
|
|
121
|
+
const data = JSON.parse(content);
|
|
122
|
+
const validation = validateSecretData(data);
|
|
123
|
+
if (!validation.valid) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`[RememberSecretPersistence] Invalid secret file format at ${secretPath}: ${validation.error}, will regenerate`
|
|
126
|
+
);
|
|
127
|
+
return null;
|
|
120
128
|
}
|
|
129
|
+
return data;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error.code === "ENOENT") {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
console.warn(`[RememberSecretPersistence] Failed to load secret from ${secretPath}: ${error.message}`);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function saveRememberSecret(secretData, options) {
|
|
139
|
+
if (!isSecretPersistenceEnabled(options)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const secretPath = resolveSecretPath(options);
|
|
143
|
+
const dir = path.dirname(secretPath);
|
|
144
|
+
const tempPath = `${secretPath}.tmp.${Date.now()}.${(0, import_utils.base64urlEncode)((0, import_utils.randomBytes)(8))}`;
|
|
145
|
+
try {
|
|
146
|
+
await (0, import_utils.mkdir)(dir, { recursive: true, mode: 448 });
|
|
147
|
+
const content = JSON.stringify(secretData, null, 2);
|
|
148
|
+
await (0, import_utils.writeFile)(tempPath, content, { mode: 384 });
|
|
149
|
+
await (0, import_utils.rename)(tempPath, secretPath);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error(`[RememberSecretPersistence] Failed to save secret to ${secretPath}: ${error.message}`);
|
|
121
153
|
try {
|
|
122
|
-
|
|
154
|
+
await (0, import_utils.unlink)(tempPath);
|
|
123
155
|
} catch {
|
|
124
|
-
return entry.value;
|
|
125
156
|
}
|
|
157
|
+
return false;
|
|
126
158
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
}
|
|
160
|
+
var cachedSecret = null;
|
|
161
|
+
var secretGenerationPromise = null;
|
|
162
|
+
function generateSecret() {
|
|
163
|
+
return (0, import_utils.base64urlEncode)((0, import_utils.randomBytes)(SECRET_BYTES));
|
|
164
|
+
}
|
|
165
|
+
async function getOrCreatePersistedSecret(options) {
|
|
166
|
+
if (cachedSecret) {
|
|
167
|
+
return cachedSecret;
|
|
134
168
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
*/
|
|
138
|
-
async exists(key) {
|
|
139
|
-
const entry = this.memory.get(key);
|
|
140
|
-
if (!entry) return false;
|
|
141
|
-
if (this.isExpired(entry)) {
|
|
142
|
-
await this.delete(key);
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
return true;
|
|
169
|
+
if (secretGenerationPromise) {
|
|
170
|
+
return secretGenerationPromise;
|
|
146
171
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const regex = pattern ? this.patternToRegex(pattern) : null;
|
|
154
|
-
const expiredKeys = [];
|
|
155
|
-
for (const [key, entry] of this.memory) {
|
|
156
|
-
if (this.isExpired(entry)) {
|
|
157
|
-
expiredKeys.push(key);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
for (const key of expiredKeys) {
|
|
161
|
-
await this.delete(key);
|
|
162
|
-
}
|
|
163
|
-
for (const [key, entry] of this.memory) {
|
|
164
|
-
if (this.isExpired(entry)) {
|
|
165
|
-
continue;
|
|
172
|
+
secretGenerationPromise = (async () => {
|
|
173
|
+
try {
|
|
174
|
+
const loaded = await loadRememberSecret(options);
|
|
175
|
+
if (loaded) {
|
|
176
|
+
cachedSecret = loaded.secret;
|
|
177
|
+
return cachedSecret;
|
|
166
178
|
}
|
|
167
|
-
|
|
168
|
-
|
|
179
|
+
const newSecret = generateSecret();
|
|
180
|
+
const secretData = {
|
|
181
|
+
secret: newSecret,
|
|
182
|
+
createdAt: Date.now(),
|
|
183
|
+
version: 1
|
|
184
|
+
};
|
|
185
|
+
if (isSecretPersistenceEnabled(options)) {
|
|
186
|
+
await saveRememberSecret(secretData, options);
|
|
169
187
|
}
|
|
170
|
-
|
|
188
|
+
cachedSecret = newSecret;
|
|
189
|
+
return cachedSecret;
|
|
190
|
+
} finally {
|
|
191
|
+
secretGenerationPromise = null;
|
|
171
192
|
}
|
|
172
|
-
|
|
193
|
+
})();
|
|
194
|
+
return secretGenerationPromise;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// plugins/plugin-remember/src/remember.crypto.ts
|
|
198
|
+
async function getBaseSecret() {
|
|
199
|
+
const envSecret = process.env["REMEMBER_SECRET"] || process.env["MCP_MEMORY_SECRET"] || process.env["MCP_SESSION_SECRET"];
|
|
200
|
+
if (envSecret) {
|
|
201
|
+
return envSecret;
|
|
173
202
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
203
|
+
return getOrCreatePersistedSecret();
|
|
204
|
+
}
|
|
205
|
+
var textEncoder = new TextEncoder();
|
|
206
|
+
async function deriveEncryptionKey(source) {
|
|
207
|
+
const salt = textEncoder.encode("remember-plugin-v1");
|
|
208
|
+
let ikm;
|
|
209
|
+
let context;
|
|
210
|
+
switch (source.type) {
|
|
211
|
+
case "session":
|
|
212
|
+
ikm = await getBaseSecret() + source.sessionId;
|
|
213
|
+
context = `remember:session:${source.sessionId}`;
|
|
214
|
+
break;
|
|
215
|
+
case "tool":
|
|
216
|
+
ikm = await getBaseSecret() + source.sessionId;
|
|
217
|
+
context = `remember:tool:${source.toolName}:${source.sessionId}`;
|
|
218
|
+
break;
|
|
219
|
+
case "user":
|
|
220
|
+
ikm = await getBaseSecret() + source.userId;
|
|
221
|
+
context = `remember:user:${source.userId}`;
|
|
222
|
+
break;
|
|
223
|
+
case "global":
|
|
224
|
+
ikm = await getBaseSecret();
|
|
225
|
+
context = "remember:global";
|
|
226
|
+
break;
|
|
227
|
+
case "custom":
|
|
228
|
+
ikm = source.key;
|
|
229
|
+
context = `remember:custom:${source.key}`;
|
|
230
|
+
break;
|
|
183
231
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
232
|
+
const ikmBytes = textEncoder.encode(ikm);
|
|
233
|
+
const info = textEncoder.encode(context);
|
|
234
|
+
return (0, import_utils2.hkdfSha256)(ikmBytes, salt, info, 32);
|
|
235
|
+
}
|
|
236
|
+
function getKeySourceForScope(scope, context) {
|
|
237
|
+
switch (scope) {
|
|
238
|
+
case "session":
|
|
239
|
+
return { type: "session", sessionId: context.sessionId };
|
|
240
|
+
case "user":
|
|
241
|
+
return { type: "user", userId: context.userId ?? "anonymous" };
|
|
242
|
+
case "tool":
|
|
243
|
+
return {
|
|
244
|
+
type: "tool",
|
|
245
|
+
toolName: context.toolName ?? "unknown",
|
|
246
|
+
sessionId: context.sessionId
|
|
247
|
+
};
|
|
248
|
+
case "global":
|
|
249
|
+
return { type: "global" };
|
|
189
250
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
}
|
|
252
|
+
var textDecoder = new TextDecoder();
|
|
253
|
+
async function encryptValue(data, keySource) {
|
|
254
|
+
const key = await deriveEncryptionKey(keySource);
|
|
255
|
+
const plaintext = textEncoder.encode(JSON.stringify(data));
|
|
256
|
+
const iv = (0, import_utils2.randomBytes)(12);
|
|
257
|
+
const { ciphertext, tag } = (0, import_utils2.encryptAesGcm)(key, plaintext, iv);
|
|
258
|
+
return {
|
|
259
|
+
alg: "A256GCM",
|
|
260
|
+
iv: (0, import_utils2.base64urlEncode)(iv),
|
|
261
|
+
tag: (0, import_utils2.base64urlEncode)(tag),
|
|
262
|
+
data: (0, import_utils2.base64urlEncode)(ciphertext)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function decryptValue(blob, keySource) {
|
|
266
|
+
try {
|
|
267
|
+
const key = await deriveEncryptionKey(keySource);
|
|
268
|
+
const iv = (0, import_utils2.base64urlDecode)(blob.iv);
|
|
269
|
+
const tag = (0, import_utils2.base64urlDecode)(blob.tag);
|
|
270
|
+
const ciphertext = (0, import_utils2.base64urlDecode)(blob.data);
|
|
271
|
+
const decrypted = (0, import_utils2.decryptAesGcm)(key, ciphertext, iv, tag);
|
|
272
|
+
return JSON.parse(textDecoder.decode(decrypted));
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
201
275
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
throw new Error(`Pattern too long: ${pattern.length} chars (max ${RememberMemoryProvider.MAX_PATTERN_LENGTH})`);
|
|
212
|
-
}
|
|
213
|
-
const wildcardCount = (pattern.match(/[*?]/g) || []).length;
|
|
214
|
-
if (wildcardCount > RememberMemoryProvider.MAX_WILDCARDS) {
|
|
215
|
-
throw new Error(`Too many wildcards in pattern: ${wildcardCount} (max ${RememberMemoryProvider.MAX_WILDCARDS})`);
|
|
276
|
+
}
|
|
277
|
+
function serializeBlob(blob) {
|
|
278
|
+
return JSON.stringify(blob);
|
|
279
|
+
}
|
|
280
|
+
function deserializeBlob(str) {
|
|
281
|
+
try {
|
|
282
|
+
const parsed = JSON.parse(str);
|
|
283
|
+
if (parsed && typeof parsed === "object" && parsed.alg === "A256GCM" && typeof parsed.iv === "string" && typeof parsed.tag === "string" && typeof parsed.data === "string") {
|
|
284
|
+
return parsed;
|
|
216
285
|
}
|
|
217
|
-
|
|
218
|
-
|
|
286
|
+
return null;
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function encryptAndSerialize(data, keySource) {
|
|
292
|
+
const blob = await encryptValue(data, keySource);
|
|
293
|
+
return serializeBlob(blob);
|
|
294
|
+
}
|
|
295
|
+
async function deserializeAndDecrypt(str, keySource) {
|
|
296
|
+
const blob = deserializeBlob(str);
|
|
297
|
+
if (!blob) return null;
|
|
298
|
+
return decryptValue(blob, keySource);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// plugins/plugin-remember/src/remember.errors.ts
|
|
302
|
+
var RememberIdentityError = class extends Error {
|
|
303
|
+
name = "RememberIdentityError";
|
|
304
|
+
constructor(message) {
|
|
305
|
+
super(message);
|
|
219
306
|
}
|
|
220
307
|
};
|
|
221
|
-
// ReDoS protection constants
|
|
222
|
-
// Pattern length allows for long prefixes (e.g., remember:session:{uuid}:user:*)
|
|
223
|
-
__publicField(RememberMemoryProvider, "MAX_PATTERN_LENGTH", 500);
|
|
224
|
-
__publicField(RememberMemoryProvider, "MAX_WILDCARDS", 20);
|
|
225
|
-
RememberMemoryProvider = __decorateClass([
|
|
226
|
-
(0, import_sdk.Provider)({
|
|
227
|
-
name: "provider:remember:memory",
|
|
228
|
-
description: "In-memory storage provider for RememberPlugin",
|
|
229
|
-
scope: import_sdk.ProviderScope.GLOBAL
|
|
230
|
-
})
|
|
231
|
-
], RememberMemoryProvider);
|
|
232
308
|
|
|
233
|
-
// plugins/plugin-remember/src/
|
|
234
|
-
var
|
|
235
|
-
var
|
|
236
|
-
var
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.client = options.client;
|
|
249
|
-
this.ownsClient = false;
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
this.ownsClient = true;
|
|
253
|
-
this.client = new import_ioredis.default({
|
|
254
|
-
lazyConnect: false,
|
|
255
|
-
maxRetriesPerRequest: 3,
|
|
256
|
-
...options.config
|
|
257
|
-
});
|
|
258
|
-
this.client.on("connect", () => {
|
|
259
|
-
if (process.env["DEBUG"] === "true") {
|
|
260
|
-
console.log("[RememberPlugin:Redis] Connected");
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
this.client.on("error", (err) => {
|
|
264
|
-
console.error("[RememberPlugin:Redis] Error:", err.message);
|
|
265
|
-
});
|
|
309
|
+
// plugins/plugin-remember/src/remember.legacy-purge.ts
|
|
310
|
+
var LEGACY_SCOPES = ["session", "tool", "user"];
|
|
311
|
+
var DEFAULT_LEGACY_PURGE_DELAY_MS = 864e5;
|
|
312
|
+
var LAYOUT_MARKER_KEY = "__layout__";
|
|
313
|
+
var LAYOUT_VERSION = 2;
|
|
314
|
+
var MAX_TIMER_MS = 2147483647;
|
|
315
|
+
var scheduled = /* @__PURE__ */ new WeakSet();
|
|
316
|
+
function parseFirstSeenAt(raw) {
|
|
317
|
+
if (!raw) return void 0;
|
|
318
|
+
try {
|
|
319
|
+
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
320
|
+
const firstSeenAt = parsed?.firstSeenAt;
|
|
321
|
+
return typeof firstSeenAt === "number" && Number.isFinite(firstSeenAt) ? firstSeenAt : void 0;
|
|
322
|
+
} catch {
|
|
323
|
+
return void 0;
|
|
266
324
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
if (ttlSeconds !== void 0) {
|
|
280
|
-
if (typeof ttlSeconds !== "number" || !Number.isFinite(ttlSeconds)) {
|
|
281
|
-
throw new Error(`Invalid TTL: expected a number, got ${typeof ttlSeconds}`);
|
|
282
|
-
}
|
|
283
|
-
if (ttlSeconds <= 0) {
|
|
284
|
-
throw new Error(`Invalid TTL: must be positive, got ${ttlSeconds}`);
|
|
285
|
-
}
|
|
286
|
-
if (!Number.isInteger(ttlSeconds)) {
|
|
287
|
-
throw new Error(`Invalid TTL: must be an integer, got ${ttlSeconds}`);
|
|
288
|
-
}
|
|
325
|
+
}
|
|
326
|
+
async function readLayoutFirstSeenAt(store, keyPrefix, logger) {
|
|
327
|
+
const key = `${keyPrefix}${LAYOUT_MARKER_KEY}`;
|
|
328
|
+
const standDown = (reason, extra = {}) => {
|
|
329
|
+
logger?.debug?.(`remember: ${reason}, legacy purge stood down`, { key, ...extra });
|
|
330
|
+
return void 0;
|
|
331
|
+
};
|
|
332
|
+
try {
|
|
333
|
+
const existing = await store.getValue(key);
|
|
334
|
+
if (existing) {
|
|
335
|
+
return parseFirstSeenAt(existing) ?? standDown("layout marker is unreadable");
|
|
289
336
|
}
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
await
|
|
294
|
-
|
|
295
|
-
await this.client.set(fullKey, strValue);
|
|
337
|
+
const marker = { version: LAYOUT_VERSION, firstSeenAt: Date.now() };
|
|
338
|
+
const serialized = JSON.stringify(marker);
|
|
339
|
+
if (!store.setIfAbsent) {
|
|
340
|
+
await store.setValue(key, serialized);
|
|
341
|
+
return marker.firstSeenAt;
|
|
296
342
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
* Retrieve a value by key.
|
|
300
|
-
*
|
|
301
|
-
* Returns the parsed JSON value if successful, or `defaultValue` if:
|
|
302
|
-
* - The key does not exist
|
|
303
|
-
* - The stored value is not valid JSON (malformed or legacy data)
|
|
304
|
-
*
|
|
305
|
-
* @param key - The key to retrieve
|
|
306
|
-
* @param defaultValue - Value to return if key doesn't exist or parsing fails
|
|
307
|
-
* @returns The parsed value as T, or defaultValue/undefined
|
|
308
|
-
*/
|
|
309
|
-
async getValue(key, defaultValue) {
|
|
310
|
-
const fullKey = this.keyPrefix + key;
|
|
311
|
-
const raw = await this.client.get(fullKey);
|
|
312
|
-
if (raw === null) return defaultValue;
|
|
313
|
-
try {
|
|
314
|
-
return JSON.parse(raw);
|
|
315
|
-
} catch {
|
|
316
|
-
return defaultValue;
|
|
343
|
+
if (await store.setIfAbsent(key, serialized)) {
|
|
344
|
+
return marker.firstSeenAt;
|
|
317
345
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
} while (cursor !== "0");
|
|
348
|
-
return result;
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Gracefully close the Redis connection.
|
|
352
|
-
* Only closes if this provider owns the client (created it internally).
|
|
353
|
-
* Externally-provided clients are left open for the caller to manage.
|
|
354
|
-
*/
|
|
355
|
-
async close() {
|
|
356
|
-
if (this.ownsClient) {
|
|
357
|
-
await this.client.quit();
|
|
346
|
+
const winner = await store.getValue(key);
|
|
347
|
+
return parseFirstSeenAt(winner) ?? standDown("layout marker vanished after a lost race");
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return standDown("could not establish the layout marker", {
|
|
350
|
+
error: error instanceof Error ? error.message : String(error)
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function purgeLegacyRememberEntries(store, keyPrefix, logger) {
|
|
355
|
+
let deleted = 0;
|
|
356
|
+
for (const scope of LEGACY_SCOPES) {
|
|
357
|
+
const pattern = `${keyPrefix}${scope}:*`;
|
|
358
|
+
let keys;
|
|
359
|
+
try {
|
|
360
|
+
keys = await store.keys(pattern);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger?.debug?.("remember: legacy purge skipped, store could not list keys", {
|
|
363
|
+
pattern,
|
|
364
|
+
error: error instanceof Error ? error.message : String(error)
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
for (const key of keys) {
|
|
369
|
+
try {
|
|
370
|
+
await store.delete(key);
|
|
371
|
+
deleted += 1;
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
358
374
|
}
|
|
359
375
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
376
|
+
if (deleted > 0) {
|
|
377
|
+
logger?.warn?.(
|
|
378
|
+
`remember: purged ${deleted} entr${deleted === 1 ? "y" : "ies"} left unreadable by the session/tool key-derivation change and the namespace encoding. This is a one-time upgrade step; set skipLegacyPurge to handle the migration yourself.`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
return deleted;
|
|
382
|
+
}
|
|
383
|
+
function scheduleLegacyRememberPurge(store, keyPrefix, options = {}) {
|
|
384
|
+
if (scheduled.has(store)) return;
|
|
385
|
+
scheduled.add(store);
|
|
386
|
+
const delayMs = options.delayMs ?? DEFAULT_LEGACY_PURGE_DELAY_MS;
|
|
387
|
+
const arm = (waitMs) => {
|
|
388
|
+
const timer = setTimeout(
|
|
389
|
+
() => {
|
|
390
|
+
void attempt().catch(() => void 0);
|
|
391
|
+
},
|
|
392
|
+
Math.min(waitMs, MAX_TIMER_MS)
|
|
393
|
+
);
|
|
394
|
+
timer.unref?.();
|
|
395
|
+
};
|
|
396
|
+
const attempt = async () => {
|
|
397
|
+
const firstSeenAt = await readLayoutFirstSeenAt(store, keyPrefix, options.logger);
|
|
398
|
+
if (firstSeenAt === void 0) return;
|
|
399
|
+
const remainingMs = firstSeenAt + delayMs - Date.now();
|
|
400
|
+
if (remainingMs > 0) {
|
|
401
|
+
arm(remainingMs);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await purgeLegacyRememberEntries(store, keyPrefix, options.logger);
|
|
405
|
+
};
|
|
406
|
+
void attempt().catch(() => void 0);
|
|
407
|
+
}
|
|
368
408
|
|
|
369
|
-
// plugins/plugin-remember/src/providers/remember-
|
|
370
|
-
var
|
|
371
|
-
var
|
|
372
|
-
|
|
409
|
+
// plugins/plugin-remember/src/providers/remember-accessor.provider.ts
|
|
410
|
+
var STATELESS_SESSION_ID = "__stateless__";
|
|
411
|
+
var STORAGE_LAYOUT_VERSION = "v2";
|
|
412
|
+
function encodeKeyPart(value) {
|
|
413
|
+
return encodeURIComponent(value);
|
|
414
|
+
}
|
|
415
|
+
var RememberAccessor = class {
|
|
416
|
+
store;
|
|
417
|
+
ctx;
|
|
418
|
+
config;
|
|
373
419
|
keyPrefix;
|
|
374
|
-
|
|
375
|
-
constructor(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
if (options.url && options.token) {
|
|
385
|
-
this.kv = vercelKv.createClient({
|
|
386
|
-
url: options.url,
|
|
387
|
-
token: options.token
|
|
388
|
-
});
|
|
389
|
-
} else {
|
|
390
|
-
this.kv = vercelKv.kv;
|
|
420
|
+
encryptionEnabled;
|
|
421
|
+
constructor(store, ctx, config, logger) {
|
|
422
|
+
this.store = store;
|
|
423
|
+
this.ctx = ctx;
|
|
424
|
+
this.config = config;
|
|
425
|
+
this.keyPrefix = config.keyPrefix ?? "remember:";
|
|
426
|
+
this.encryptionEnabled = config.encryption?.enabled !== false;
|
|
427
|
+
if (!config.skipLegacyPurge) {
|
|
428
|
+
scheduleLegacyRememberPurge(store, this.keyPrefix, { delayMs: config.legacyPurgeDelayMs, logger });
|
|
391
429
|
}
|
|
392
|
-
this.keyPrefix = options.keyPrefix ?? "remember:";
|
|
393
|
-
this.defaultTTL = options.defaultTTL;
|
|
394
430
|
}
|
|
395
|
-
|
|
396
|
-
|
|
431
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
432
|
+
// Public API
|
|
433
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
434
|
+
/**
|
|
435
|
+
* Store a value in memory.
|
|
436
|
+
*
|
|
437
|
+
* @param key - The key to store under
|
|
438
|
+
* @param value - The value to store (any JSON-serializable data)
|
|
439
|
+
* @param options - Storage options (scope, ttl, brand, metadata)
|
|
440
|
+
*/
|
|
441
|
+
async set(key, value, options = {}) {
|
|
442
|
+
const scope = options.scope ?? "session";
|
|
443
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
444
|
+
const entry = {
|
|
445
|
+
value,
|
|
446
|
+
brand: options.brand,
|
|
447
|
+
createdAt: Date.now(),
|
|
448
|
+
updatedAt: Date.now(),
|
|
449
|
+
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : void 0,
|
|
450
|
+
metadata: options.metadata
|
|
451
|
+
};
|
|
452
|
+
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
453
|
+
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
397
454
|
}
|
|
398
455
|
/**
|
|
399
|
-
*
|
|
456
|
+
* Retrieve a value from memory.
|
|
457
|
+
*
|
|
458
|
+
* @param key - The key to retrieve
|
|
459
|
+
* @param options - Retrieval options (scope, defaultValue)
|
|
460
|
+
* @returns The stored value or defaultValue if not found
|
|
400
461
|
*/
|
|
401
|
-
async
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
462
|
+
async get(key, options = {}) {
|
|
463
|
+
const scope = options.scope ?? "session";
|
|
464
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
465
|
+
const raw = await this.store.getValue(storageKey);
|
|
466
|
+
if (!raw) return options.defaultValue;
|
|
467
|
+
const entry = this.encryptionEnabled ? await deserializeAndDecrypt(raw, this.getKeySource(scope)) : this.parseEntry(raw);
|
|
468
|
+
if (!entry) return options.defaultValue;
|
|
469
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
470
|
+
await this.forget(key, { scope });
|
|
471
|
+
return options.defaultValue;
|
|
409
472
|
}
|
|
473
|
+
return entry.value;
|
|
410
474
|
}
|
|
411
475
|
/**
|
|
412
|
-
*
|
|
476
|
+
* Get the full entry including metadata.
|
|
477
|
+
*
|
|
478
|
+
* @param key - The key to retrieve
|
|
479
|
+
* @param options - Retrieval options (scope)
|
|
480
|
+
* @returns The full entry or undefined if not found
|
|
413
481
|
*/
|
|
414
|
-
async
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
482
|
+
async getEntry(key, options = {}) {
|
|
483
|
+
const scope = options.scope ?? "session";
|
|
484
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
485
|
+
const raw = await this.store.getValue(storageKey);
|
|
486
|
+
if (!raw) return void 0;
|
|
487
|
+
const entry = this.encryptionEnabled ? await deserializeAndDecrypt(raw, this.getKeySource(scope)) : this.parseEntry(raw);
|
|
488
|
+
if (!entry) return void 0;
|
|
489
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
490
|
+
await this.forget(key, { scope });
|
|
491
|
+
return void 0;
|
|
424
492
|
}
|
|
425
|
-
return
|
|
493
|
+
return entry;
|
|
426
494
|
}
|
|
427
495
|
/**
|
|
428
|
-
*
|
|
496
|
+
* Forget (delete) a value from memory.
|
|
497
|
+
*
|
|
498
|
+
* @param key - The key to forget
|
|
499
|
+
* @param options - Options (scope)
|
|
429
500
|
*/
|
|
430
|
-
async
|
|
431
|
-
const
|
|
432
|
-
|
|
501
|
+
async forget(key, options = {}) {
|
|
502
|
+
const scope = options.scope ?? "session";
|
|
503
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
504
|
+
await this.store.delete(storageKey);
|
|
433
505
|
}
|
|
434
506
|
/**
|
|
435
|
-
* Check if a key exists.
|
|
507
|
+
* Check if a key is remembered (exists and not expired).
|
|
508
|
+
*
|
|
509
|
+
* @param key - The key to check
|
|
510
|
+
* @param options - Options (scope)
|
|
511
|
+
* @returns true if the key exists
|
|
436
512
|
*/
|
|
437
|
-
async
|
|
438
|
-
const
|
|
439
|
-
|
|
513
|
+
async knows(key, options = {}) {
|
|
514
|
+
const scope = options.scope ?? "session";
|
|
515
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
516
|
+
return this.store.exists(storageKey);
|
|
440
517
|
}
|
|
441
518
|
/**
|
|
442
|
-
* List keys
|
|
443
|
-
*
|
|
519
|
+
* List all remembered keys for a scope.
|
|
520
|
+
*
|
|
521
|
+
* @param options - Options (scope, pattern)
|
|
522
|
+
* @returns Array of keys (without the scope prefix)
|
|
444
523
|
*/
|
|
445
|
-
async
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const [nextCursor, keys] = await this.kv.scan(cursor, {
|
|
452
|
-
match: searchPattern,
|
|
453
|
-
count: 100
|
|
454
|
-
});
|
|
455
|
-
cursor = nextCursor;
|
|
456
|
-
for (const key of keys) {
|
|
457
|
-
result.push(key.slice(this.keyPrefix.length));
|
|
458
|
-
}
|
|
459
|
-
} while (cursor !== 0);
|
|
460
|
-
} catch {
|
|
461
|
-
try {
|
|
462
|
-
const keys = await this.kv.keys(searchPattern);
|
|
463
|
-
for (const key of keys) {
|
|
464
|
-
result.push(key.slice(this.keyPrefix.length));
|
|
465
|
-
}
|
|
466
|
-
} catch {
|
|
467
|
-
console.warn("[RememberPlugin:VercelKV] keys() operation not supported");
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
return result;
|
|
524
|
+
async list(options = {}) {
|
|
525
|
+
const scope = options.scope ?? "session";
|
|
526
|
+
const scopePrefix = this.buildScopePrefix(scope);
|
|
527
|
+
const fullPattern = scopePrefix + (options.pattern ?? "*");
|
|
528
|
+
const keys = await this.store.keys(fullPattern);
|
|
529
|
+
return keys.map((k) => k.slice(scopePrefix.length));
|
|
471
530
|
}
|
|
472
531
|
/**
|
|
473
|
-
*
|
|
474
|
-
*
|
|
532
|
+
* Update an existing entry's value while preserving metadata.
|
|
533
|
+
*
|
|
534
|
+
* @param key - The key to update
|
|
535
|
+
* @param value - The new value
|
|
536
|
+
* @param options - Options (scope, ttl)
|
|
475
537
|
*/
|
|
476
|
-
async
|
|
477
|
-
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
// plugins/plugin-remember/src/remember.crypto.ts
|
|
491
|
-
var import_utils2 = require("@frontmcp/utils");
|
|
492
|
-
|
|
493
|
-
// plugins/plugin-remember/src/remember.secret-persistence.ts
|
|
494
|
-
var path = __toESM(require("path"));
|
|
495
|
-
var import_lazy_zod = require("@frontmcp/lazy-zod");
|
|
496
|
-
var import_utils = require("@frontmcp/utils");
|
|
497
|
-
var DEFAULT_SECRET_FILE_PATH = ".frontmcp/remember-secret.json";
|
|
498
|
-
var SECRET_BYTES = 32;
|
|
499
|
-
var rememberSecretDataSchema = import_lazy_zod.z.object({
|
|
500
|
-
secret: import_lazy_zod.z.string().min(32),
|
|
501
|
-
// base64url of 32 bytes is ~43 chars
|
|
502
|
-
createdAt: import_lazy_zod.z.number().positive().int(),
|
|
503
|
-
version: import_lazy_zod.z.literal(1)
|
|
504
|
-
});
|
|
505
|
-
function validateSecretData(data) {
|
|
506
|
-
const result = rememberSecretDataSchema.safeParse(data);
|
|
507
|
-
if (!result.success) {
|
|
508
|
-
return { valid: false, error: result.error.issues[0]?.message ?? "Invalid secret structure" };
|
|
509
|
-
}
|
|
510
|
-
const parsed = result.data;
|
|
511
|
-
const now = Date.now();
|
|
512
|
-
if (parsed.createdAt > now + 6e4) {
|
|
513
|
-
return { valid: false, error: "createdAt is in the future" };
|
|
514
|
-
}
|
|
515
|
-
const hundredYearsMs = 100 * 365 * 24 * 60 * 60 * 1e3;
|
|
516
|
-
if (parsed.createdAt < now - hundredYearsMs) {
|
|
517
|
-
return { valid: false, error: "createdAt is too old" };
|
|
538
|
+
async update(key, value, options = {}) {
|
|
539
|
+
const scope = options.scope ?? "session";
|
|
540
|
+
const existing = await this.getEntry(key, { scope });
|
|
541
|
+
if (!existing) return false;
|
|
542
|
+
const entry = {
|
|
543
|
+
...existing,
|
|
544
|
+
value,
|
|
545
|
+
updatedAt: Date.now(),
|
|
546
|
+
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : existing.expiresAt
|
|
547
|
+
};
|
|
548
|
+
const storageKey = this.buildStorageKey(key, scope);
|
|
549
|
+
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
550
|
+
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
551
|
+
return true;
|
|
518
552
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
553
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
554
|
+
// Convenience Methods
|
|
555
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
556
|
+
/**
|
|
557
|
+
* Get the session ID from context.
|
|
558
|
+
*/
|
|
559
|
+
get sessionId() {
|
|
560
|
+
return this.ctx.sessionId;
|
|
525
561
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
return
|
|
562
|
+
/**
|
|
563
|
+
* Get the user ID from context (if available).
|
|
564
|
+
*/
|
|
565
|
+
get userId() {
|
|
566
|
+
const authInfo = this.ctx.authInfo;
|
|
567
|
+
return authInfo?.extra?.["userId"] ?? authInfo?.extra?.["sub"] ?? authInfo?.clientId ?? void 0;
|
|
532
568
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
569
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
570
|
+
// Private Methods
|
|
571
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
572
|
+
/**
|
|
573
|
+
* Build the full storage key including scope prefix.
|
|
574
|
+
*/
|
|
575
|
+
buildStorageKey(key, scope) {
|
|
576
|
+
return this.buildScopePrefix(scope) + key;
|
|
538
577
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
578
|
+
/**
|
|
579
|
+
* The identity that namespaces session- and tool-scoped storage
|
|
580
|
+
* (GHSA-225p-f8jh-f3rh).
|
|
581
|
+
*
|
|
582
|
+
* In stateless mode the transport injects the literal session id `__stateless__` into every
|
|
583
|
+
* request, so using it as a namespace put every client's memory under the same keys. A
|
|
584
|
+
* stateless request carries no session identity: the authenticated principal is the only
|
|
585
|
+
* per-client identity available, and where there is none the request has no business
|
|
586
|
+
* reading or writing per-client memory at all.
|
|
587
|
+
*/
|
|
588
|
+
resolveSessionIdentity() {
|
|
589
|
+
const sessionId = this.ctx.sessionId;
|
|
590
|
+
if (sessionId !== STATELESS_SESSION_ID) {
|
|
591
|
+
return sessionId;
|
|
549
592
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
return null;
|
|
593
|
+
const userId = this.userId;
|
|
594
|
+
if (userId) {
|
|
595
|
+
return `stateless-user:${userId}`;
|
|
554
596
|
}
|
|
555
|
-
|
|
556
|
-
|
|
597
|
+
throw new RememberIdentityError(
|
|
598
|
+
`Remember cannot use session or tool scope for an unauthenticated stateless request: every such request shares the session id "__stateless__", so the data would be shared across all clients. Authenticate the request, use a stateful transport, or choose the 'global' scope if the data really is shared.`
|
|
599
|
+
);
|
|
557
600
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
601
|
+
/**
|
|
602
|
+
* Build the scope-specific prefix.
|
|
603
|
+
*
|
|
604
|
+
* Every variable component is encoded, because the separator is also a legal character in
|
|
605
|
+
* a user id, a tool name and a memory key. Left raw, identity `a` with key `b:c` produces
|
|
606
|
+
* the same storage key as identity `a:b` with key `c` — one client reading or overwriting
|
|
607
|
+
* another's value through nothing more exotic than a colon in a name.
|
|
608
|
+
*/
|
|
609
|
+
buildScopePrefix(scope) {
|
|
610
|
+
switch (scope) {
|
|
611
|
+
case "session":
|
|
612
|
+
return `${this.keyPrefix}${STORAGE_LAYOUT_VERSION}:session:${encodeKeyPart(this.resolveSessionIdentity())}:`;
|
|
613
|
+
case "user": {
|
|
614
|
+
const userId = this.userId;
|
|
615
|
+
if (!userId) {
|
|
616
|
+
throw new RememberIdentityError(
|
|
617
|
+
"Remember cannot use user scope without an authenticated user: all unauthenticated callers would share one namespace. Authenticate the request or use the 'global' scope."
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return `${this.keyPrefix}${STORAGE_LAYOUT_VERSION}:user:${encodeKeyPart(userId)}:`;
|
|
621
|
+
}
|
|
622
|
+
case "tool": {
|
|
623
|
+
const toolName = this.ctx.flow?.name ?? "unknown";
|
|
624
|
+
return `${this.keyPrefix}${STORAGE_LAYOUT_VERSION}:tool:${encodeKeyPart(toolName)}:${encodeKeyPart(this.resolveSessionIdentity())}:`;
|
|
625
|
+
}
|
|
626
|
+
case "global":
|
|
627
|
+
return `${this.keyPrefix}global:`;
|
|
628
|
+
}
|
|
562
629
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Get the encryption key source for a scope.
|
|
632
|
+
*/
|
|
633
|
+
getKeySource(scope) {
|
|
634
|
+
const needsSessionIdentity = scope === "session" || scope === "tool";
|
|
635
|
+
return getKeySourceForScope(scope, {
|
|
636
|
+
sessionId: needsSessionIdentity ? this.resolveSessionIdentity() : this.ctx.sessionId,
|
|
637
|
+
userId: this.userId,
|
|
638
|
+
toolName: this.ctx.flow?.name
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Parse a JSON entry (when encryption is disabled).
|
|
643
|
+
*/
|
|
644
|
+
parseEntry(raw) {
|
|
574
645
|
try {
|
|
575
|
-
|
|
646
|
+
return JSON.parse(raw);
|
|
576
647
|
} catch {
|
|
648
|
+
return null;
|
|
577
649
|
}
|
|
578
|
-
return false;
|
|
579
650
|
}
|
|
651
|
+
};
|
|
652
|
+
RememberAccessor = __decorateClass([
|
|
653
|
+
(0, import_sdk.Provider)({
|
|
654
|
+
name: "provider:remember:accessor",
|
|
655
|
+
description: "Context-scoped accessor for RememberPlugin storage",
|
|
656
|
+
scope: import_sdk.ProviderScope.CONTEXT
|
|
657
|
+
})
|
|
658
|
+
], RememberAccessor);
|
|
659
|
+
function createRememberAccessor(store, ctx, config, logger) {
|
|
660
|
+
return new RememberAccessor(store, ctx, config, logger);
|
|
580
661
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
return secretGenerationPromise;
|
|
662
|
+
|
|
663
|
+
// plugins/plugin-remember/src/providers/remember-memory.provider.ts
|
|
664
|
+
var import_sdk2 = require("@frontmcp/sdk");
|
|
665
|
+
var MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
666
|
+
var RememberMemoryProvider = class {
|
|
667
|
+
memory = /* @__PURE__ */ new Map();
|
|
668
|
+
sweeper;
|
|
669
|
+
constructor(sweepIntervalSeconds = 60) {
|
|
670
|
+
this.sweeper = setInterval(() => this.sweep(), sweepIntervalSeconds * 1e3);
|
|
671
|
+
this.sweeper.unref?.();
|
|
592
672
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
673
|
+
/**
|
|
674
|
+
* Store a value with optional TTL.
|
|
675
|
+
* Always JSON-serializes the value for consistent storage/retrieval.
|
|
676
|
+
*/
|
|
677
|
+
async setValue(key, value, ttlSeconds) {
|
|
678
|
+
const strValue = JSON.stringify(value);
|
|
679
|
+
const existing = this.memory.get(key);
|
|
680
|
+
if (existing?.timeout) clearTimeout(existing.timeout);
|
|
681
|
+
const entry = { value: strValue };
|
|
682
|
+
if (ttlSeconds && ttlSeconds > 0) {
|
|
683
|
+
const ttlMs = ttlSeconds * 1e3;
|
|
684
|
+
entry.expiresAt = Date.now() + ttlMs;
|
|
685
|
+
if (ttlMs <= MAX_TIMEOUT_MS) {
|
|
686
|
+
entry.timeout = setTimeout(() => {
|
|
687
|
+
const e = this.memory.get(key);
|
|
688
|
+
if (e && e.expiresAt && e.expiresAt <= Date.now()) {
|
|
689
|
+
this.memory.delete(key);
|
|
690
|
+
}
|
|
691
|
+
}, ttlMs);
|
|
692
|
+
entry.timeout.unref?.();
|
|
608
693
|
}
|
|
609
|
-
cachedSecret = newSecret;
|
|
610
|
-
return cachedSecret;
|
|
611
|
-
} finally {
|
|
612
|
-
secretGenerationPromise = null;
|
|
613
694
|
}
|
|
614
|
-
|
|
615
|
-
return secretGenerationPromise;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// plugins/plugin-remember/src/remember.crypto.ts
|
|
619
|
-
async function getBaseSecret() {
|
|
620
|
-
const envSecret = process.env["REMEMBER_SECRET"] || process.env["MCP_MEMORY_SECRET"] || process.env["MCP_SESSION_SECRET"];
|
|
621
|
-
if (envSecret) {
|
|
622
|
-
return envSecret;
|
|
695
|
+
this.memory.set(key, entry);
|
|
623
696
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
break;
|
|
636
|
-
case "tool":
|
|
637
|
-
ikm = source.sessionId;
|
|
638
|
-
context = `remember:tool:${source.toolName}:${source.sessionId}`;
|
|
639
|
-
break;
|
|
640
|
-
case "user":
|
|
641
|
-
ikm = await getBaseSecret() + source.userId;
|
|
642
|
-
context = `remember:user:${source.userId}`;
|
|
643
|
-
break;
|
|
644
|
-
case "global":
|
|
645
|
-
ikm = await getBaseSecret();
|
|
646
|
-
context = "remember:global";
|
|
647
|
-
break;
|
|
648
|
-
case "custom":
|
|
649
|
-
ikm = source.key;
|
|
650
|
-
context = `remember:custom:${source.key}`;
|
|
651
|
-
break;
|
|
697
|
+
/**
|
|
698
|
+
* Store a value only if the key is absent.
|
|
699
|
+
*
|
|
700
|
+
* A single-threaded runtime makes the check-then-set indivisible; an expired entry counts as
|
|
701
|
+
* absent, matching what `getValue` would report.
|
|
702
|
+
*/
|
|
703
|
+
async setIfAbsent(key, value, ttlSeconds) {
|
|
704
|
+
const existing = this.memory.get(key);
|
|
705
|
+
if (existing && !this.isExpired(existing)) return false;
|
|
706
|
+
await this.setValue(key, value, ttlSeconds);
|
|
707
|
+
return true;
|
|
652
708
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
return
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
709
|
+
/**
|
|
710
|
+
* Retrieve a value by key.
|
|
711
|
+
* JSON-parses the stored value to match the setValue serialization.
|
|
712
|
+
*/
|
|
713
|
+
async getValue(key, defaultValue) {
|
|
714
|
+
const entry = this.memory.get(key);
|
|
715
|
+
if (!entry) return defaultValue;
|
|
716
|
+
if (this.isExpired(entry)) {
|
|
717
|
+
await this.delete(key);
|
|
718
|
+
return defaultValue;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
return JSON.parse(entry.value);
|
|
722
|
+
} catch {
|
|
723
|
+
return entry.value;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Delete a key.
|
|
728
|
+
*/
|
|
729
|
+
async delete(key) {
|
|
730
|
+
const entry = this.memory.get(key);
|
|
731
|
+
if (entry?.timeout) clearTimeout(entry.timeout);
|
|
732
|
+
this.memory.delete(key);
|
|
671
733
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
data: (0, import_utils2.base64urlEncode)(ciphertext)
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
async function decryptValue(blob, keySource) {
|
|
687
|
-
try {
|
|
688
|
-
const key = await deriveEncryptionKey(keySource);
|
|
689
|
-
const iv = (0, import_utils2.base64urlDecode)(blob.iv);
|
|
690
|
-
const tag = (0, import_utils2.base64urlDecode)(blob.tag);
|
|
691
|
-
const ciphertext = (0, import_utils2.base64urlDecode)(blob.data);
|
|
692
|
-
const decrypted = (0, import_utils2.decryptAesGcm)(key, ciphertext, iv, tag);
|
|
693
|
-
return JSON.parse(textDecoder.decode(decrypted));
|
|
694
|
-
} catch {
|
|
695
|
-
return null;
|
|
734
|
+
/**
|
|
735
|
+
* Check if a key exists (and is not expired).
|
|
736
|
+
*/
|
|
737
|
+
async exists(key) {
|
|
738
|
+
const entry = this.memory.get(key);
|
|
739
|
+
if (!entry) return false;
|
|
740
|
+
if (this.isExpired(entry)) {
|
|
741
|
+
await this.delete(key);
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
return true;
|
|
696
745
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
746
|
+
/**
|
|
747
|
+
* List keys matching a glob-style pattern.
|
|
748
|
+
* Supports * and ? wildcards.
|
|
749
|
+
*/
|
|
750
|
+
async keys(pattern) {
|
|
751
|
+
const result = [];
|
|
752
|
+
const regex = pattern ? this.patternToRegex(pattern) : null;
|
|
753
|
+
const expiredKeys = [];
|
|
754
|
+
for (const [key, entry] of this.memory) {
|
|
755
|
+
if (this.isExpired(entry)) {
|
|
756
|
+
expiredKeys.push(key);
|
|
757
|
+
}
|
|
706
758
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
759
|
+
for (const key of expiredKeys) {
|
|
760
|
+
await this.delete(key);
|
|
761
|
+
}
|
|
762
|
+
for (const [key, entry] of this.memory) {
|
|
763
|
+
if (this.isExpired(entry)) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (regex && !regex.test(key)) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
result.push(key);
|
|
770
|
+
}
|
|
771
|
+
return result;
|
|
710
772
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// plugins/plugin-remember/src/providers/remember-accessor.provider.ts
|
|
723
|
-
var RememberAccessor = class {
|
|
724
|
-
store;
|
|
725
|
-
ctx;
|
|
726
|
-
config;
|
|
727
|
-
keyPrefix;
|
|
728
|
-
encryptionEnabled;
|
|
729
|
-
constructor(store, ctx, config) {
|
|
730
|
-
this.store = store;
|
|
731
|
-
this.ctx = ctx;
|
|
732
|
-
this.config = config;
|
|
733
|
-
this.keyPrefix = config.keyPrefix ?? "remember:";
|
|
734
|
-
this.encryptionEnabled = config.encryption?.enabled !== false;
|
|
773
|
+
/**
|
|
774
|
+
* Gracefully close the provider.
|
|
775
|
+
*/
|
|
776
|
+
async close() {
|
|
777
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
778
|
+
for (const [, entry] of this.memory) {
|
|
779
|
+
if (entry.timeout) clearTimeout(entry.timeout);
|
|
780
|
+
}
|
|
781
|
+
this.memory.clear();
|
|
735
782
|
}
|
|
736
783
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
737
|
-
//
|
|
784
|
+
// Private Methods
|
|
738
785
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
786
|
+
isExpired(entry) {
|
|
787
|
+
return entry.expiresAt !== void 0 && entry.expiresAt <= Date.now();
|
|
788
|
+
}
|
|
739
789
|
/**
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
* @param key - The key to store under
|
|
743
|
-
* @param value - The value to store (any JSON-serializable data)
|
|
744
|
-
* @param options - Storage options (scope, ttl, brand, metadata)
|
|
790
|
+
* Periodically remove expired keys to keep memory tidy.
|
|
745
791
|
*/
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : void 0,
|
|
755
|
-
metadata: options.metadata
|
|
756
|
-
};
|
|
757
|
-
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
758
|
-
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
792
|
+
sweep() {
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
for (const [key, entry] of this.memory) {
|
|
795
|
+
if (entry.expiresAt !== void 0 && entry.expiresAt <= now) {
|
|
796
|
+
if (entry.timeout) clearTimeout(entry.timeout);
|
|
797
|
+
this.memory.delete(key);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
759
800
|
}
|
|
760
801
|
/**
|
|
761
|
-
*
|
|
802
|
+
* Convert a glob-style pattern to a RegExp.
|
|
803
|
+
* * matches any sequence of characters
|
|
804
|
+
* ? matches any single character
|
|
762
805
|
*
|
|
763
|
-
*
|
|
764
|
-
* @param options - Retrieval options (scope, defaultValue)
|
|
765
|
-
* @returns The stored value or defaultValue if not found
|
|
806
|
+
* Includes ReDoS protection: validates pattern length and wildcard count.
|
|
766
807
|
*/
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const raw = await this.store.getValue(storageKey);
|
|
771
|
-
if (!raw) return options.defaultValue;
|
|
772
|
-
const entry = this.encryptionEnabled ? await deserializeAndDecrypt(raw, this.getKeySource(scope)) : this.parseEntry(raw);
|
|
773
|
-
if (!entry) return options.defaultValue;
|
|
774
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
775
|
-
await this.forget(key, { scope });
|
|
776
|
-
return options.defaultValue;
|
|
808
|
+
patternToRegex(pattern) {
|
|
809
|
+
if (pattern.length > RememberMemoryProvider.MAX_PATTERN_LENGTH) {
|
|
810
|
+
throw new Error(`Pattern too long: ${pattern.length} chars (max ${RememberMemoryProvider.MAX_PATTERN_LENGTH})`);
|
|
777
811
|
}
|
|
778
|
-
|
|
812
|
+
const wildcardCount = (pattern.match(/[*?]/g) || []).length;
|
|
813
|
+
if (wildcardCount > RememberMemoryProvider.MAX_WILDCARDS) {
|
|
814
|
+
throw new Error(`Too many wildcards in pattern: ${wildcardCount} (max ${RememberMemoryProvider.MAX_WILDCARDS})`);
|
|
815
|
+
}
|
|
816
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
817
|
+
return new RegExp(`^${escaped}$`);
|
|
779
818
|
}
|
|
819
|
+
};
|
|
820
|
+
// ReDoS protection constants
|
|
821
|
+
// Pattern length allows for long prefixes (e.g., remember:session:{uuid}:user:*)
|
|
822
|
+
__publicField(RememberMemoryProvider, "MAX_PATTERN_LENGTH", 500);
|
|
823
|
+
__publicField(RememberMemoryProvider, "MAX_WILDCARDS", 20);
|
|
824
|
+
RememberMemoryProvider = __decorateClass([
|
|
825
|
+
(0, import_sdk2.Provider)({
|
|
826
|
+
name: "provider:remember:memory",
|
|
827
|
+
description: "In-memory storage provider for RememberPlugin",
|
|
828
|
+
scope: import_sdk2.ProviderScope.GLOBAL
|
|
829
|
+
})
|
|
830
|
+
], RememberMemoryProvider);
|
|
831
|
+
|
|
832
|
+
// plugins/plugin-remember/src/providers/remember-redis.provider.ts
|
|
833
|
+
var import_ioredis = __toESM(require("ioredis"));
|
|
834
|
+
var import_sdk3 = require("@frontmcp/sdk");
|
|
835
|
+
var RememberRedisProvider = class {
|
|
836
|
+
client;
|
|
780
837
|
/**
|
|
781
|
-
*
|
|
838
|
+
* Prefix prepended to all Redis keys.
|
|
839
|
+
* Include any separator (e.g., "myapp:" or "user:123:") as part of the prefix.
|
|
840
|
+
*/
|
|
841
|
+
keyPrefix;
|
|
842
|
+
/** True if this provider created the client (and should close it), false if externally provided */
|
|
843
|
+
ownsClient;
|
|
844
|
+
constructor(options) {
|
|
845
|
+
this.keyPrefix = options.keyPrefix ?? "";
|
|
846
|
+
if (options.type === "redis-client") {
|
|
847
|
+
this.client = options.client;
|
|
848
|
+
this.ownsClient = false;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
this.ownsClient = true;
|
|
852
|
+
this.client = new import_ioredis.default({
|
|
853
|
+
lazyConnect: false,
|
|
854
|
+
maxRetriesPerRequest: 3,
|
|
855
|
+
...options.config
|
|
856
|
+
});
|
|
857
|
+
this.client.on("connect", () => {
|
|
858
|
+
if (process.env["DEBUG"] === "true") {
|
|
859
|
+
console.log("[RememberPlugin:Redis] Connected");
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
this.client.on("error", (err) => {
|
|
863
|
+
console.error("[RememberPlugin:Redis] Error:", err.message);
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Store a value with optional TTL.
|
|
782
868
|
*
|
|
783
|
-
* @param key - The key to
|
|
784
|
-
* @param
|
|
785
|
-
* @
|
|
869
|
+
* @param key - The key to store under
|
|
870
|
+
* @param value - The value to store (must not be undefined)
|
|
871
|
+
* @param ttlSeconds - Optional TTL in seconds (must be positive integer if provided)
|
|
872
|
+
* @throws Error if value is undefined or ttlSeconds is invalid
|
|
786
873
|
*/
|
|
787
|
-
async
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
874
|
+
async setValue(key, value, ttlSeconds) {
|
|
875
|
+
if (value === void 0) {
|
|
876
|
+
throw new Error("Cannot store undefined value. Use null or delete the key instead.");
|
|
877
|
+
}
|
|
878
|
+
if (ttlSeconds !== void 0) {
|
|
879
|
+
if (typeof ttlSeconds !== "number" || !Number.isFinite(ttlSeconds)) {
|
|
880
|
+
throw new Error(`Invalid TTL: expected a number, got ${typeof ttlSeconds}`);
|
|
881
|
+
}
|
|
882
|
+
if (ttlSeconds <= 0) {
|
|
883
|
+
throw new Error(`Invalid TTL: must be positive, got ${ttlSeconds}`);
|
|
884
|
+
}
|
|
885
|
+
if (!Number.isInteger(ttlSeconds)) {
|
|
886
|
+
throw new Error(`Invalid TTL: must be an integer, got ${ttlSeconds}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const fullKey = this.keyPrefix + key;
|
|
890
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
891
|
+
if (ttlSeconds !== void 0 && ttlSeconds > 0) {
|
|
892
|
+
await this.client.set(fullKey, strValue, "EX", ttlSeconds);
|
|
893
|
+
} else {
|
|
894
|
+
await this.client.set(fullKey, strValue);
|
|
797
895
|
}
|
|
798
|
-
return entry;
|
|
799
896
|
}
|
|
800
897
|
/**
|
|
801
|
-
*
|
|
898
|
+
* Store a value only if the key is absent, via Redis `SET ... NX`.
|
|
802
899
|
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
900
|
+
* `set` resolves to `'OK'` when it created the key and `null` when the key already existed,
|
|
901
|
+
* so the whole check-and-write is one round trip and two callers racing cannot both win.
|
|
805
902
|
*/
|
|
806
|
-
async
|
|
807
|
-
const
|
|
808
|
-
const
|
|
809
|
-
await this.
|
|
903
|
+
async setIfAbsent(key, value, ttlSeconds) {
|
|
904
|
+
const fullKey = this.keyPrefix + key;
|
|
905
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
906
|
+
const result = ttlSeconds !== void 0 && ttlSeconds > 0 ? await this.client.set(fullKey, strValue, "EX", ttlSeconds, "NX") : await this.client.set(fullKey, strValue, "NX");
|
|
907
|
+
return result === "OK";
|
|
810
908
|
}
|
|
811
909
|
/**
|
|
812
|
-
*
|
|
910
|
+
* Retrieve a value by key.
|
|
813
911
|
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
912
|
+
* Returns the parsed JSON value if successful, or `defaultValue` if:
|
|
913
|
+
* - The key does not exist
|
|
914
|
+
* - The stored value is not valid JSON (malformed or legacy data)
|
|
915
|
+
*
|
|
916
|
+
* @param key - The key to retrieve
|
|
917
|
+
* @param defaultValue - Value to return if key doesn't exist or parsing fails
|
|
918
|
+
* @returns The parsed value as T, or defaultValue/undefined
|
|
817
919
|
*/
|
|
818
|
-
async
|
|
819
|
-
const
|
|
820
|
-
const
|
|
821
|
-
|
|
920
|
+
async getValue(key, defaultValue) {
|
|
921
|
+
const fullKey = this.keyPrefix + key;
|
|
922
|
+
const raw = await this.client.get(fullKey);
|
|
923
|
+
if (raw === null) return defaultValue;
|
|
924
|
+
try {
|
|
925
|
+
return JSON.parse(raw);
|
|
926
|
+
} catch {
|
|
927
|
+
return defaultValue;
|
|
928
|
+
}
|
|
822
929
|
}
|
|
823
930
|
/**
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
* @param options - Options (scope, pattern)
|
|
827
|
-
* @returns Array of keys (without the scope prefix)
|
|
931
|
+
* Delete a key.
|
|
828
932
|
*/
|
|
829
|
-
async
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
const fullPattern = scopePrefix + (options.pattern ?? "*");
|
|
833
|
-
const keys = await this.store.keys(fullPattern);
|
|
834
|
-
return keys.map((k) => k.slice(scopePrefix.length));
|
|
933
|
+
async delete(key) {
|
|
934
|
+
const fullKey = this.keyPrefix + key;
|
|
935
|
+
await this.client.del(fullKey);
|
|
835
936
|
}
|
|
836
937
|
/**
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
* @param key - The key to update
|
|
840
|
-
* @param value - The new value
|
|
841
|
-
* @param options - Options (scope, ttl)
|
|
938
|
+
* Check if a key exists.
|
|
842
939
|
*/
|
|
843
|
-
async
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
if (!existing) return false;
|
|
847
|
-
const entry = {
|
|
848
|
-
...existing,
|
|
849
|
-
value,
|
|
850
|
-
updatedAt: Date.now(),
|
|
851
|
-
expiresAt: options.ttl ? Date.now() + options.ttl * 1e3 : existing.expiresAt
|
|
852
|
-
};
|
|
853
|
-
const storageKey = this.buildStorageKey(key, scope);
|
|
854
|
-
const serialized = this.encryptionEnabled ? await encryptAndSerialize(entry, this.getKeySource(scope)) : JSON.stringify(entry);
|
|
855
|
-
await this.store.setValue(storageKey, serialized, options.ttl);
|
|
856
|
-
return true;
|
|
940
|
+
async exists(key) {
|
|
941
|
+
const fullKey = this.keyPrefix + key;
|
|
942
|
+
return await this.client.exists(fullKey) === 1;
|
|
857
943
|
}
|
|
858
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
859
|
-
// Convenience Methods
|
|
860
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
861
944
|
/**
|
|
862
|
-
*
|
|
945
|
+
* List keys matching a pattern.
|
|
946
|
+
* Uses Redis SCAN for efficient iteration.
|
|
863
947
|
*/
|
|
864
|
-
|
|
865
|
-
|
|
948
|
+
async keys(pattern) {
|
|
949
|
+
const searchPattern = this.keyPrefix + (pattern ?? "*");
|
|
950
|
+
const result = [];
|
|
951
|
+
let cursor = "0";
|
|
952
|
+
do {
|
|
953
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", searchPattern, "COUNT", 100);
|
|
954
|
+
cursor = nextCursor;
|
|
955
|
+
for (const key of keys) {
|
|
956
|
+
result.push(key.slice(this.keyPrefix.length));
|
|
957
|
+
}
|
|
958
|
+
} while (cursor !== "0");
|
|
959
|
+
return result;
|
|
866
960
|
}
|
|
867
961
|
/**
|
|
868
|
-
*
|
|
962
|
+
* Gracefully close the Redis connection.
|
|
963
|
+
* Only closes if this provider owns the client (created it internally).
|
|
964
|
+
* Externally-provided clients are left open for the caller to manage.
|
|
869
965
|
*/
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
966
|
+
async close() {
|
|
967
|
+
if (this.ownsClient) {
|
|
968
|
+
await this.client.quit();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
RememberRedisProvider = __decorateClass([
|
|
973
|
+
(0, import_sdk3.Provider)({
|
|
974
|
+
name: "provider:remember:redis",
|
|
975
|
+
description: "Redis-based storage provider for RememberPlugin",
|
|
976
|
+
scope: import_sdk3.ProviderScope.GLOBAL
|
|
977
|
+
})
|
|
978
|
+
], RememberRedisProvider);
|
|
979
|
+
|
|
980
|
+
// plugins/plugin-remember/src/providers/remember-vercel-kv.provider.ts
|
|
981
|
+
var import_sdk4 = require("@frontmcp/sdk");
|
|
982
|
+
var RememberVercelKvProvider = class {
|
|
983
|
+
kv;
|
|
984
|
+
keyPrefix;
|
|
985
|
+
defaultTTL;
|
|
986
|
+
constructor(options = {}) {
|
|
987
|
+
const vercelKv = require("@vercel/kv");
|
|
988
|
+
const hasUrl = options.url !== void 0;
|
|
989
|
+
const hasToken = options.token !== void 0;
|
|
990
|
+
if (hasUrl !== hasToken) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
`RememberVercelKvProvider: Both 'url' and 'token' must be provided together, or neither. Received: url=${hasUrl ? "provided" : "missing"}, token=${hasToken ? "provided" : "missing"}`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
if (options.url && options.token) {
|
|
996
|
+
this.kv = vercelKv.createClient({
|
|
997
|
+
url: options.url,
|
|
998
|
+
token: options.token
|
|
999
|
+
});
|
|
1000
|
+
} else {
|
|
1001
|
+
this.kv = vercelKv.kv;
|
|
1002
|
+
}
|
|
1003
|
+
this.keyPrefix = options.keyPrefix ?? "remember:";
|
|
1004
|
+
this.defaultTTL = options.defaultTTL;
|
|
1005
|
+
}
|
|
1006
|
+
prefixKey(key) {
|
|
1007
|
+
return `${this.keyPrefix}${key}`;
|
|
873
1008
|
}
|
|
874
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
875
|
-
// Private Methods
|
|
876
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
877
1009
|
/**
|
|
878
|
-
*
|
|
1010
|
+
* Store a value with optional TTL.
|
|
879
1011
|
*/
|
|
880
|
-
|
|
881
|
-
|
|
1012
|
+
async setValue(key, value, ttlSeconds) {
|
|
1013
|
+
const fullKey = this.prefixKey(key);
|
|
1014
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
1015
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
1016
|
+
if (ttl && ttl > 0) {
|
|
1017
|
+
await this.kv.set(fullKey, strValue, { ex: ttl });
|
|
1018
|
+
} else {
|
|
1019
|
+
await this.kv.set(fullKey, strValue);
|
|
1020
|
+
}
|
|
882
1021
|
}
|
|
883
1022
|
/**
|
|
884
|
-
*
|
|
1023
|
+
* Store a value only if the key is absent, via the `nx` option.
|
|
1024
|
+
*
|
|
1025
|
+
* Resolves to `'OK'` when it created the key and `null` when the key already existed, so two
|
|
1026
|
+
* callers racing cannot both win.
|
|
885
1027
|
*/
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1028
|
+
async setIfAbsent(key, value, ttlSeconds) {
|
|
1029
|
+
const fullKey = this.prefixKey(key);
|
|
1030
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
1031
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
1032
|
+
const result = ttl && ttl > 0 ? await this.kv.set(fullKey, strValue, { nx: true, ex: ttl }) : await this.kv.set(fullKey, strValue, { nx: true });
|
|
1033
|
+
return result === "OK";
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Retrieve a value by key.
|
|
1037
|
+
*/
|
|
1038
|
+
async getValue(key, defaultValue) {
|
|
1039
|
+
const fullKey = this.prefixKey(key);
|
|
1040
|
+
const raw = await this.kv.get(fullKey);
|
|
1041
|
+
if (raw === null || raw === void 0) return defaultValue;
|
|
1042
|
+
if (typeof raw === "string") {
|
|
1043
|
+
try {
|
|
1044
|
+
return JSON.parse(raw);
|
|
1045
|
+
} catch {
|
|
1046
|
+
return raw;
|
|
895
1047
|
}
|
|
896
|
-
case "global":
|
|
897
|
-
return `${this.keyPrefix}global:`;
|
|
898
1048
|
}
|
|
1049
|
+
return raw;
|
|
899
1050
|
}
|
|
900
1051
|
/**
|
|
901
|
-
*
|
|
1052
|
+
* Delete a key.
|
|
902
1053
|
*/
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
userId: this.userId,
|
|
907
|
-
toolName: this.ctx.flow?.name
|
|
908
|
-
});
|
|
1054
|
+
async delete(key) {
|
|
1055
|
+
const fullKey = this.prefixKey(key);
|
|
1056
|
+
await this.kv.del(fullKey);
|
|
909
1057
|
}
|
|
910
1058
|
/**
|
|
911
|
-
*
|
|
1059
|
+
* Check if a key exists.
|
|
912
1060
|
*/
|
|
913
|
-
|
|
1061
|
+
async exists(key) {
|
|
1062
|
+
const fullKey = this.prefixKey(key);
|
|
1063
|
+
return await this.kv.exists(fullKey) === 1;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* List keys matching a pattern.
|
|
1067
|
+
* Uses SCAN for efficient iteration.
|
|
1068
|
+
*/
|
|
1069
|
+
async keys(pattern) {
|
|
1070
|
+
const searchPattern = this.prefixKey(pattern ?? "*");
|
|
1071
|
+
const result = [];
|
|
914
1072
|
try {
|
|
915
|
-
|
|
1073
|
+
let cursor = 0;
|
|
1074
|
+
do {
|
|
1075
|
+
const [nextCursor, keys] = await this.kv.scan(cursor, {
|
|
1076
|
+
match: searchPattern,
|
|
1077
|
+
count: 100
|
|
1078
|
+
});
|
|
1079
|
+
cursor = nextCursor;
|
|
1080
|
+
for (const key of keys) {
|
|
1081
|
+
result.push(key.slice(this.keyPrefix.length));
|
|
1082
|
+
}
|
|
1083
|
+
} while (cursor !== 0);
|
|
916
1084
|
} catch {
|
|
917
|
-
|
|
1085
|
+
try {
|
|
1086
|
+
const keys = await this.kv.keys(searchPattern);
|
|
1087
|
+
for (const key of keys) {
|
|
1088
|
+
result.push(key.slice(this.keyPrefix.length));
|
|
1089
|
+
}
|
|
1090
|
+
} catch {
|
|
1091
|
+
console.warn("[RememberPlugin:VercelKV] keys() operation not supported");
|
|
1092
|
+
}
|
|
918
1093
|
}
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Gracefully close the provider.
|
|
1098
|
+
* No-op for Vercel KV as it uses stateless REST API.
|
|
1099
|
+
*/
|
|
1100
|
+
async close() {
|
|
919
1101
|
}
|
|
920
1102
|
};
|
|
921
|
-
|
|
1103
|
+
RememberVercelKvProvider = __decorateClass([
|
|
922
1104
|
(0, import_sdk4.Provider)({
|
|
923
|
-
name: "provider:remember:
|
|
924
|
-
description: "
|
|
925
|
-
scope: import_sdk4.ProviderScope.
|
|
1105
|
+
name: "provider:remember:vercel-kv",
|
|
1106
|
+
description: "Vercel KV-based storage provider for RememberPlugin",
|
|
1107
|
+
scope: import_sdk4.ProviderScope.GLOBAL
|
|
926
1108
|
})
|
|
927
|
-
],
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1109
|
+
], RememberVercelKvProvider);
|
|
1110
|
+
|
|
1111
|
+
// plugins/plugin-remember/src/remember.symbols.ts
|
|
1112
|
+
var RememberStoreToken = /* @__PURE__ */ Symbol(
|
|
1113
|
+
"plugin:remember:store"
|
|
1114
|
+
);
|
|
1115
|
+
var RememberConfigToken = /* @__PURE__ */ Symbol(
|
|
1116
|
+
"plugin:remember:config"
|
|
1117
|
+
);
|
|
1118
|
+
var RememberAccessorToken = /* @__PURE__ */ Symbol(
|
|
1119
|
+
"plugin:remember:accessor"
|
|
1120
|
+
);
|
|
931
1121
|
|
|
932
1122
|
// plugins/plugin-remember/src/remember.plugin.ts
|
|
933
1123
|
var RememberPlugin = class extends import_sdk5.DynamicPlugin {
|
|
@@ -1042,8 +1232,8 @@ __publicField(RememberPlugin, "dynamicProviders", (options) => {
|
|
|
1042
1232
|
name: "remember:accessor",
|
|
1043
1233
|
provide: RememberAccessorToken,
|
|
1044
1234
|
scope: import_sdk5.ProviderScope.CONTEXT,
|
|
1045
|
-
inject: () => [RememberStoreToken, import_sdk5.FRONTMCP_CONTEXT, RememberConfigToken],
|
|
1046
|
-
useFactory: (store, ctx, cfg) => createRememberAccessor(store, ctx, cfg)
|
|
1235
|
+
inject: () => [RememberStoreToken, import_sdk5.FRONTMCP_CONTEXT, RememberConfigToken, import_sdk5.FrontMcpLogger],
|
|
1236
|
+
useFactory: (store, ctx, cfg, logger) => createRememberAccessor(store, ctx, cfg, logger)
|
|
1047
1237
|
});
|
|
1048
1238
|
return providers;
|
|
1049
1239
|
});
|
|
@@ -1304,6 +1494,7 @@ function tryGetRemember(ctx) {
|
|
|
1304
1494
|
RememberAccessor,
|
|
1305
1495
|
RememberAccessorToken,
|
|
1306
1496
|
RememberConfigToken,
|
|
1497
|
+
RememberIdentityError,
|
|
1307
1498
|
RememberMemoryProvider,
|
|
1308
1499
|
RememberPlugin,
|
|
1309
1500
|
RememberRedisProvider,
|