@prmichaelsen/remember-mcp 3.14.12 → 3.14.14
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/CHANGELOG.md +20 -0
- package/agent/milestones/milestone-18-performance-tuning.md +45 -0
- package/agent/progress.yaml +86 -2
- package/agent/tasks/task-77-parallelize-checkiffriend.md +62 -0
- package/agent/tasks/task-78-cache-checkiffriend.md +78 -0
- package/agent/tasks/task-79-memoize-core-services.md +75 -0
- package/agent/tasks/task-80-parallelize-startup-health-checks.md +57 -0
- package/agent/tasks/task-81-optimize-ghost-config-block-unblock.md +64 -0
- package/agent/tasks/task-82-native-weaviate-offset.md +69 -0
- package/agent/tasks/task-83-eliminate-redundant-validatetoken.md +53 -0
- package/agent/tasks/task-84-static-imports-server-factory.md +52 -0
- package/dist/core-services.d.ts +3 -1
- package/dist/server-factory.js +2668 -3189
- package/dist/server.js +20 -19
- package/dist/services/access-control.d.ts +5 -5
- package/dist/services/ghost-config.service.d.ts +2 -0
- package/package.json +1 -1
- package/src/core-services.ts +16 -2
- package/src/server-factory.ts +5 -3
- package/src/server.ts +5 -3
- package/src/services/access-control.spec.ts +11 -11
- package/src/services/access-control.ts +73 -7
- package/src/services/ghost-config.service.spec.ts +22 -15
- package/src/services/ghost-config.service.ts +9 -15
- package/src/tools/search-memory.ts +4 -6
package/dist/server.js
CHANGED
|
@@ -3676,16 +3676,22 @@ function getMemoryCollection(userId) {
|
|
|
3676
3676
|
var coreLogger = createLogger("info");
|
|
3677
3677
|
var tokenService = new ConfirmationTokenService(coreLogger);
|
|
3678
3678
|
var preferencesService = new PreferencesDatabaseService(coreLogger);
|
|
3679
|
+
var coreServicesCache = /* @__PURE__ */ new Map();
|
|
3679
3680
|
function createCoreServices(userId) {
|
|
3681
|
+
const cached = coreServicesCache.get(userId);
|
|
3682
|
+
if (cached)
|
|
3683
|
+
return cached;
|
|
3680
3684
|
const collection = getMemoryCollection(userId);
|
|
3681
3685
|
const weaviateClient = getWeaviateClient();
|
|
3682
|
-
|
|
3686
|
+
const services = {
|
|
3683
3687
|
memory: new MemoryService(collection, userId, coreLogger),
|
|
3684
3688
|
relationship: new RelationshipService(collection, userId, coreLogger),
|
|
3685
3689
|
space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger),
|
|
3686
3690
|
preferences: preferencesService,
|
|
3687
3691
|
token: tokenService
|
|
3688
3692
|
};
|
|
3693
|
+
coreServicesCache.set(userId, services);
|
|
3694
|
+
return services;
|
|
3689
3695
|
}
|
|
3690
3696
|
|
|
3691
3697
|
// src/tools/create-memory.ts
|
|
@@ -3953,8 +3959,8 @@ async function handleSearchMemory(args, userId, authContext) {
|
|
|
3953
3959
|
const combinedFilters = combineFiltersWithAnd([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter((f) => f !== null));
|
|
3954
3960
|
const searchOptions = {
|
|
3955
3961
|
alpha,
|
|
3956
|
-
limit
|
|
3957
|
-
|
|
3962
|
+
limit,
|
|
3963
|
+
offset
|
|
3958
3964
|
};
|
|
3959
3965
|
if (combinedFilters) {
|
|
3960
3966
|
searchOptions.filters = combinedFilters;
|
|
@@ -3966,10 +3972,9 @@ async function handleSearchMemory(args, userId, authContext) {
|
|
|
3966
3972
|
deletedFilter: args.deleted_filter || "exclude"
|
|
3967
3973
|
});
|
|
3968
3974
|
const results = await collection.query.hybrid(args.query, searchOptions);
|
|
3969
|
-
const paginatedResults = results.objects.slice(offset);
|
|
3970
3975
|
const memories = [];
|
|
3971
3976
|
const relationships = [];
|
|
3972
|
-
for (const obj of
|
|
3977
|
+
for (const obj of results.objects) {
|
|
3973
3978
|
const doc = {
|
|
3974
3979
|
id: obj.uuid,
|
|
3975
3980
|
...obj.properties
|
|
@@ -6013,13 +6018,10 @@ async function removeUserTrust2(ownerUserId, targetUserId) {
|
|
|
6013
6018
|
});
|
|
6014
6019
|
}
|
|
6015
6020
|
async function blockUser2(ownerUserId, targetUserId) {
|
|
6016
|
-
const current = await getGhostConfig2(ownerUserId);
|
|
6017
|
-
if (current.blocked_users.includes(targetUserId)) {
|
|
6018
|
-
return;
|
|
6019
|
-
}
|
|
6020
|
-
const blocked_users = [...current.blocked_users, targetUserId];
|
|
6021
6021
|
const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
|
|
6022
|
-
await setDocument(collectionPath, docId, {
|
|
6022
|
+
await setDocument(collectionPath, docId, {
|
|
6023
|
+
blocked_users: FieldValue.arrayUnion(targetUserId)
|
|
6024
|
+
}, { merge: true });
|
|
6023
6025
|
logger.info("User blocked from ghost access", {
|
|
6024
6026
|
service: SERVICE,
|
|
6025
6027
|
ownerUserId,
|
|
@@ -6027,13 +6029,10 @@ async function blockUser2(ownerUserId, targetUserId) {
|
|
|
6027
6029
|
});
|
|
6028
6030
|
}
|
|
6029
6031
|
async function unblockUser2(ownerUserId, targetUserId) {
|
|
6030
|
-
const current = await getGhostConfig2(ownerUserId);
|
|
6031
|
-
if (!current.blocked_users.includes(targetUserId)) {
|
|
6032
|
-
return;
|
|
6033
|
-
}
|
|
6034
|
-
const blocked_users = current.blocked_users.filter((id) => id !== targetUserId);
|
|
6035
6032
|
const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
|
|
6036
|
-
await setDocument(collectionPath, docId, {
|
|
6033
|
+
await setDocument(collectionPath, docId, {
|
|
6034
|
+
blocked_users: FieldValue.arrayRemove(targetUserId)
|
|
6035
|
+
}, { merge: true });
|
|
6037
6036
|
logger.info("User unblocked from ghost access", {
|
|
6038
6037
|
service: SERVICE,
|
|
6039
6038
|
ownerUserId,
|
|
@@ -6249,8 +6248,10 @@ async function initServer() {
|
|
|
6249
6248
|
logger.info("Connecting to databases...");
|
|
6250
6249
|
await initWeaviateClient();
|
|
6251
6250
|
initFirestore();
|
|
6252
|
-
const weaviateOk = await
|
|
6253
|
-
|
|
6251
|
+
const [weaviateOk, firestoreOk] = await Promise.all([
|
|
6252
|
+
testWeaviateConnection(),
|
|
6253
|
+
testFirestoreConnection()
|
|
6254
|
+
]);
|
|
6254
6255
|
if (!weaviateOk || !firestoreOk) {
|
|
6255
6256
|
throw new Error("Database connection failed");
|
|
6256
6257
|
}
|
|
@@ -89,13 +89,13 @@ export declare function resetBlock(ownerUserId: string, accessorUserId: string,
|
|
|
89
89
|
/**
|
|
90
90
|
* Resolve the trust level for an accessor from GhostConfig.
|
|
91
91
|
*
|
|
92
|
-
* Priority: per_user_trust → default_friend_trust → default_public_trust → 0
|
|
92
|
+
* Priority: per_user_trust → default_friend_trust (if friends) → default_public_trust → 0
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
* context in M16 (friend list, social graph). For now, non-per_user accessors
|
|
96
|
-
* fall through to default_public_trust.
|
|
94
|
+
* Checks Firestore relationships collection to determine friend status.
|
|
97
95
|
*/
|
|
98
|
-
export declare function resolveAccessorTrustLevel(ghostConfig: GhostConfig, accessorUserId: string): number
|
|
96
|
+
export declare function resolveAccessorTrustLevel(ghostConfig: GhostConfig, ownerUserId: string, accessorUserId: string): Promise<number>;
|
|
97
|
+
/** Clear the friend status cache (for tests or forced refresh) */
|
|
98
|
+
export declare function invalidateFriendCache(): void;
|
|
99
99
|
/**
|
|
100
100
|
* Format an AccessResult into a human-readable message.
|
|
101
101
|
*/
|
|
@@ -30,10 +30,12 @@ export declare function setUserTrust(ownerUserId: string, targetUserId: string,
|
|
|
30
30
|
export declare function removeUserTrust(ownerUserId: string, targetUserId: string): Promise<void>;
|
|
31
31
|
/**
|
|
32
32
|
* Block a user from ghost access.
|
|
33
|
+
* Uses FieldValue.arrayUnion for atomic add without reading first (idempotent).
|
|
33
34
|
*/
|
|
34
35
|
export declare function blockUser(ownerUserId: string, targetUserId: string): Promise<void>;
|
|
35
36
|
/**
|
|
36
37
|
* Unblock a user from ghost access.
|
|
38
|
+
* Uses FieldValue.arrayRemove for atomic remove without reading first (safe if not present).
|
|
37
39
|
*/
|
|
38
40
|
export declare function unblockUser(ownerUserId: string, targetUserId: string): Promise<void>;
|
|
39
41
|
/**
|
package/package.json
CHANGED
package/src/core-services.ts
CHANGED
|
@@ -30,21 +30,35 @@ const coreLogger: Logger = createLogger('info');
|
|
|
30
30
|
const tokenService = new ConfirmationTokenService(coreLogger);
|
|
31
31
|
const preferencesService = new PreferencesDatabaseService(coreLogger);
|
|
32
32
|
|
|
33
|
+
/** Cached CoreServices per userId — avoids re-instantiation on every tool call */
|
|
34
|
+
const coreServicesCache = new Map<string, CoreServices>();
|
|
35
|
+
|
|
33
36
|
/**
|
|
34
|
-
* Create core services scoped to a specific user.
|
|
37
|
+
* Create (or return cached) core services scoped to a specific user.
|
|
35
38
|
* Call after databases have been initialized (initWeaviateClient + initFirestore).
|
|
36
39
|
*/
|
|
37
40
|
export function createCoreServices(userId: string): CoreServices {
|
|
41
|
+
const cached = coreServicesCache.get(userId);
|
|
42
|
+
if (cached) return cached;
|
|
43
|
+
|
|
38
44
|
const collection = getMemoryCollection(userId);
|
|
39
45
|
const weaviateClient = getWeaviateClient();
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
const services: CoreServices = {
|
|
42
48
|
memory: new MemoryService(collection, userId, coreLogger),
|
|
43
49
|
relationship: new RelationshipService(collection, userId, coreLogger),
|
|
44
50
|
space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger),
|
|
45
51
|
preferences: preferencesService,
|
|
46
52
|
token: tokenService,
|
|
47
53
|
};
|
|
54
|
+
|
|
55
|
+
coreServicesCache.set(userId, services);
|
|
56
|
+
return services;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Clear the core services cache (for tests or forced refresh) */
|
|
60
|
+
export function invalidateCoreServicesCache(): void {
|
|
61
|
+
coreServicesCache.clear();
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
export { coreLogger, tokenService, preferencesService };
|
package/src/server-factory.ts
CHANGED
|
@@ -45,6 +45,10 @@ import { querySpaceTool, handleQuerySpace } from './tools/query-space.js';
|
|
|
45
45
|
import { moderateTool, handleModerate } from './tools/moderate.js';
|
|
46
46
|
import { ghostConfigTool, handleGhostConfig } from './tools/ghost-config.js';
|
|
47
47
|
|
|
48
|
+
// Import services (static — avoids dynamic import overhead on hot path)
|
|
49
|
+
import { getGhostConfig } from './services/ghost-config.service.js';
|
|
50
|
+
import { resolveAccessorTrustLevel } from './services/access-control.js';
|
|
51
|
+
|
|
48
52
|
export interface ServerOptions {
|
|
49
53
|
name?: string;
|
|
50
54
|
version?: string;
|
|
@@ -175,10 +179,8 @@ export async function createServer(
|
|
|
175
179
|
// Resolve ghost mode trust level from Firestore if ghost mode is configured
|
|
176
180
|
let resolvedGhostMode: import('./types/auth.js').GhostModeContext | undefined;
|
|
177
181
|
if (options.ghostMode) {
|
|
178
|
-
const { getGhostConfig } = await import('./services/ghost-config.service.js');
|
|
179
|
-
const { resolveAccessorTrustLevel } = await import('./services/access-control.js');
|
|
180
182
|
const ghostConfig = await getGhostConfig(options.ghostMode.owner_user_id);
|
|
181
|
-
const trustLevel = resolveAccessorTrustLevel(ghostConfig, options.ghostMode.accessor_user_id);
|
|
183
|
+
const trustLevel = await resolveAccessorTrustLevel(ghostConfig, options.ghostMode.owner_user_id, options.ghostMode.accessor_user_id);
|
|
182
184
|
resolvedGhostMode = {
|
|
183
185
|
owner_user_id: options.ghostMode.owner_user_id,
|
|
184
186
|
accessor_user_id: options.ghostMode.accessor_user_id,
|
package/src/server.ts
CHANGED
|
@@ -57,9 +57,11 @@ async function initServer(): Promise<Server> {
|
|
|
57
57
|
await initWeaviateClient();
|
|
58
58
|
initFirestore();
|
|
59
59
|
|
|
60
|
-
// Test connections
|
|
61
|
-
const weaviateOk = await
|
|
62
|
-
|
|
60
|
+
// Test connections in parallel
|
|
61
|
+
const [weaviateOk, firestoreOk] = await Promise.all([
|
|
62
|
+
testWeaviateConnection(),
|
|
63
|
+
testFirestoreConnection(),
|
|
64
|
+
]);
|
|
63
65
|
|
|
64
66
|
if (!weaviateOk || !firestoreOk) {
|
|
65
67
|
throw new Error('Database connection failed');
|
|
@@ -207,36 +207,36 @@ describe('checkMemoryAccess', () => {
|
|
|
207
207
|
// ─── resolveAccessorTrustLevel ─────────────────────────────────────────────
|
|
208
208
|
|
|
209
209
|
describe('resolveAccessorTrustLevel', () => {
|
|
210
|
-
it('returns per_user_trust when set', () => {
|
|
210
|
+
it('returns per_user_trust when set', async () => {
|
|
211
211
|
const config = createEnabledGhostConfig({ per_user_trust: { 'alice': 0.9 } });
|
|
212
|
-
expect(resolveAccessorTrustLevel(config, 'alice')).toBe(0.9);
|
|
212
|
+
expect(await resolveAccessorTrustLevel(config, 'owner', 'alice')).toBe(0.9);
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it('falls through to default_public_trust when no per_user_trust', () => {
|
|
215
|
+
it('falls through to default_public_trust when no per_user_trust', async () => {
|
|
216
216
|
const config = createEnabledGhostConfig({ default_public_trust: 0.3 });
|
|
217
|
-
expect(resolveAccessorTrustLevel(config, 'stranger')).toBe(0.3);
|
|
217
|
+
expect(await resolveAccessorTrustLevel(config, 'owner', 'stranger')).toBe(0.3);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
it('returns 0 when default_public_trust not set', () => {
|
|
220
|
+
it('returns 0 when default_public_trust not set', async () => {
|
|
221
221
|
const config = createEnabledGhostConfig({ default_public_trust: 0 });
|
|
222
|
-
expect(resolveAccessorTrustLevel(config, 'unknown')).toBe(0);
|
|
222
|
+
expect(await resolveAccessorTrustLevel(config, 'owner', 'unknown')).toBe(0);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
it('per_user_trust takes priority over default', () => {
|
|
225
|
+
it('per_user_trust takes priority over default', async () => {
|
|
226
226
|
const config = createEnabledGhostConfig({
|
|
227
227
|
default_public_trust: 0.1,
|
|
228
228
|
per_user_trust: { 'bob': 0.8 },
|
|
229
229
|
});
|
|
230
|
-
expect(resolveAccessorTrustLevel(config, 'bob')).toBe(0.8);
|
|
231
|
-
expect(resolveAccessorTrustLevel(config, 'carol')).toBe(0.1);
|
|
230
|
+
expect(await resolveAccessorTrustLevel(config, 'owner', 'bob')).toBe(0.8);
|
|
231
|
+
expect(await resolveAccessorTrustLevel(config, 'owner', 'carol')).toBe(0.1);
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
-
it('per_user_trust of 0 is used (not falsy fallthrough)', () => {
|
|
234
|
+
it('per_user_trust of 0 is used (not falsy fallthrough)', async () => {
|
|
235
235
|
const config = createEnabledGhostConfig({
|
|
236
236
|
default_public_trust: 0.5,
|
|
237
237
|
per_user_trust: { 'restricted': 0 },
|
|
238
238
|
});
|
|
239
|
-
expect(resolveAccessorTrustLevel(config, 'restricted')).toBe(0);
|
|
239
|
+
expect(await resolveAccessorTrustLevel(config, 'owner', 'restricted')).toBe(0);
|
|
240
240
|
});
|
|
241
241
|
});
|
|
242
242
|
|
|
@@ -164,7 +164,7 @@ export async function checkMemoryAccess(
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// 5. Check trust level
|
|
167
|
-
const accessorTrust = resolveAccessorTrustLevel(ghostConfig, accessorUserId);
|
|
167
|
+
const accessorTrust = await resolveAccessorTrustLevel(ghostConfig, ownerUserId, accessorUserId);
|
|
168
168
|
const memoryTrust = memory.trust;
|
|
169
169
|
|
|
170
170
|
if (!isTrustSufficient(memoryTrust, accessorTrust)) {
|
|
@@ -250,22 +250,88 @@ export async function resetBlock(
|
|
|
250
250
|
/**
|
|
251
251
|
* Resolve the trust level for an accessor from GhostConfig.
|
|
252
252
|
*
|
|
253
|
-
* Priority: per_user_trust → default_friend_trust → default_public_trust → 0
|
|
253
|
+
* Priority: per_user_trust → default_friend_trust (if friends) → default_public_trust → 0
|
|
254
254
|
*
|
|
255
|
-
*
|
|
256
|
-
* context in M16 (friend list, social graph). For now, non-per_user accessors
|
|
257
|
-
* fall through to default_public_trust.
|
|
255
|
+
* Checks Firestore relationships collection to determine friend status.
|
|
258
256
|
*/
|
|
259
|
-
export function resolveAccessorTrustLevel(
|
|
257
|
+
export async function resolveAccessorTrustLevel(
|
|
258
|
+
ghostConfig: GhostConfig,
|
|
259
|
+
ownerUserId: string,
|
|
260
|
+
accessorUserId: string
|
|
261
|
+
): Promise<number> {
|
|
260
262
|
// 1. Per-user override
|
|
261
263
|
if (accessorUserId in ghostConfig.per_user_trust) {
|
|
262
264
|
return ghostConfig.per_user_trust[accessorUserId];
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
// 2.
|
|
267
|
+
// 2. Check if accessor is a friend
|
|
268
|
+
const isFriend = await checkIfFriend(ownerUserId, accessorUserId);
|
|
269
|
+
|
|
270
|
+
if (isFriend) {
|
|
271
|
+
return ghostConfig.default_friend_trust ?? 0.25;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 3. Fall through to public trust
|
|
266
275
|
return ghostConfig.default_public_trust ?? 0;
|
|
267
276
|
}
|
|
268
277
|
|
|
278
|
+
// ─── Friend Cache ─────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** TTL cache for friend status lookups (avoids redundant Firestore reads) */
|
|
281
|
+
const friendCache = new Map<string, { result: boolean; expiresAt: number }>();
|
|
282
|
+
const FRIEND_CACHE_TTL_MS = 60_000; // 60 seconds
|
|
283
|
+
|
|
284
|
+
/** Clear the friend status cache (for tests or forced refresh) */
|
|
285
|
+
export function invalidateFriendCache(): void {
|
|
286
|
+
friendCache.clear();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check if accessor is a friend of owner by querying relationships collection.
|
|
291
|
+
* Results are cached for 60 seconds per user pair.
|
|
292
|
+
*/
|
|
293
|
+
async function checkIfFriend(ownerUserId: string, accessorUserId: string): Promise<boolean> {
|
|
294
|
+
// Check cache first (sorted key so a:b and b:a hit the same entry)
|
|
295
|
+
const cacheKey = [ownerUserId, accessorUserId].sort().join(':');
|
|
296
|
+
const cached = friendCache.get(cacheKey);
|
|
297
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
298
|
+
return cached.result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const { queryDocuments } = await import('../firestore/init.js');
|
|
303
|
+
const BASE = process.env.FIRESTORE_BASE_PATH || 'agentbase';
|
|
304
|
+
|
|
305
|
+
// Run both direction queries in parallel
|
|
306
|
+
const [forward, reverse] = await Promise.all([
|
|
307
|
+
queryDocuments(`${BASE}.relationships`, {
|
|
308
|
+
where: [
|
|
309
|
+
{ field: 'from_user_id', op: '==', value: ownerUserId },
|
|
310
|
+
{ field: 'to_user_id', op: '==', value: accessorUserId },
|
|
311
|
+
{ field: 'friend', op: '==', value: true },
|
|
312
|
+
],
|
|
313
|
+
limit: 1,
|
|
314
|
+
}),
|
|
315
|
+
queryDocuments(`${BASE}.relationships`, {
|
|
316
|
+
where: [
|
|
317
|
+
{ field: 'from_user_id', op: '==', value: accessorUserId },
|
|
318
|
+
{ field: 'to_user_id', op: '==', value: ownerUserId },
|
|
319
|
+
{ field: 'friend', op: '==', value: true },
|
|
320
|
+
],
|
|
321
|
+
limit: 1,
|
|
322
|
+
}),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const result = forward.length > 0 || reverse.length > 0;
|
|
326
|
+
friendCache.set(cacheKey, { result, expiresAt: Date.now() + FRIEND_CACHE_TTL_MS });
|
|
327
|
+
return result;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.error('[checkIfFriend] Error checking friend status:', error);
|
|
330
|
+
// On error, treat as not friends (safer default)
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
269
335
|
// ─── Message Formatting ───────────────────────────────────────────────────
|
|
270
336
|
|
|
271
337
|
/**
|
|
@@ -15,6 +15,10 @@ import * as firestoreInit from '../firestore/init';
|
|
|
15
15
|
jest.mock('../firestore/init', () => ({
|
|
16
16
|
getDocument: jest.fn(),
|
|
17
17
|
setDocument: jest.fn(),
|
|
18
|
+
FieldValue: {
|
|
19
|
+
arrayUnion: (...elements: any[]) => ({ _type: 'arrayUnion', _value: elements }),
|
|
20
|
+
arrayRemove: (...elements: any[]) => ({ _type: 'arrayRemove', _value: elements }),
|
|
21
|
+
},
|
|
18
22
|
}));
|
|
19
23
|
|
|
20
24
|
jest.mock('../firestore/paths', () => ({
|
|
@@ -179,8 +183,7 @@ describe('GhostConfigService', () => {
|
|
|
179
183
|
});
|
|
180
184
|
|
|
181
185
|
describe('blockUser', () => {
|
|
182
|
-
it('adds user to blocked list', async () => {
|
|
183
|
-
mockGetDocument.mockResolvedValue({ blocked_users: [] });
|
|
186
|
+
it('adds user to blocked list using FieldValue.arrayUnion', async () => {
|
|
184
187
|
mockSetDocument.mockResolvedValue(undefined);
|
|
185
188
|
|
|
186
189
|
await blockUser('owner-1', 'bad-user');
|
|
@@ -188,21 +191,23 @@ describe('GhostConfigService', () => {
|
|
|
188
191
|
expect(mockSetDocument).toHaveBeenCalledWith(
|
|
189
192
|
'test-remember-mcp.users/owner-1/ghost_config',
|
|
190
193
|
'settings',
|
|
191
|
-
{ blocked_users: ['bad-user'] },
|
|
194
|
+
{ blocked_users: { _type: 'arrayUnion', _value: ['bad-user'] } },
|
|
192
195
|
{ merge: true }
|
|
193
196
|
);
|
|
197
|
+
// No read needed — arrayUnion is idempotent
|
|
198
|
+
expect(mockGetDocument).not.toHaveBeenCalled();
|
|
194
199
|
});
|
|
195
200
|
|
|
196
|
-
it('does not duplicate already blocked user', async () => {
|
|
197
|
-
|
|
201
|
+
it('does not duplicate already blocked user (arrayUnion is idempotent)', async () => {
|
|
202
|
+
mockSetDocument.mockResolvedValue(undefined);
|
|
198
203
|
|
|
199
204
|
await blockUser('owner-1', 'bad-user');
|
|
200
205
|
|
|
201
|
-
|
|
206
|
+
// arrayUnion handles dedup atomically — always writes
|
|
207
|
+
expect(mockSetDocument).toHaveBeenCalledTimes(1);
|
|
202
208
|
});
|
|
203
209
|
|
|
204
|
-
it('preserves existing blocked users', async () => {
|
|
205
|
-
mockGetDocument.mockResolvedValue({ blocked_users: ['user-a'] });
|
|
210
|
+
it('preserves existing blocked users (arrayUnion appends atomically)', async () => {
|
|
206
211
|
mockSetDocument.mockResolvedValue(undefined);
|
|
207
212
|
|
|
208
213
|
await blockUser('owner-1', 'user-b');
|
|
@@ -210,15 +215,14 @@ describe('GhostConfigService', () => {
|
|
|
210
215
|
expect(mockSetDocument).toHaveBeenCalledWith(
|
|
211
216
|
'test-remember-mcp.users/owner-1/ghost_config',
|
|
212
217
|
'settings',
|
|
213
|
-
{ blocked_users:
|
|
218
|
+
{ blocked_users: { _type: 'arrayUnion', _value: ['user-b'] } },
|
|
214
219
|
{ merge: true }
|
|
215
220
|
);
|
|
216
221
|
});
|
|
217
222
|
});
|
|
218
223
|
|
|
219
224
|
describe('unblockUser', () => {
|
|
220
|
-
it('removes user from blocked list', async () => {
|
|
221
|
-
mockGetDocument.mockResolvedValue({ blocked_users: ['bad-user', 'other'] });
|
|
225
|
+
it('removes user from blocked list using FieldValue.arrayRemove', async () => {
|
|
222
226
|
mockSetDocument.mockResolvedValue(undefined);
|
|
223
227
|
|
|
224
228
|
await unblockUser('owner-1', 'bad-user');
|
|
@@ -226,17 +230,20 @@ describe('GhostConfigService', () => {
|
|
|
226
230
|
expect(mockSetDocument).toHaveBeenCalledWith(
|
|
227
231
|
'test-remember-mcp.users/owner-1/ghost_config',
|
|
228
232
|
'settings',
|
|
229
|
-
{ blocked_users: ['
|
|
233
|
+
{ blocked_users: { _type: 'arrayRemove', _value: ['bad-user'] } },
|
|
230
234
|
{ merge: true }
|
|
231
235
|
);
|
|
236
|
+
// No read needed — arrayRemove is safe if element not present
|
|
237
|
+
expect(mockGetDocument).not.toHaveBeenCalled();
|
|
232
238
|
});
|
|
233
239
|
|
|
234
|
-
it('
|
|
235
|
-
|
|
240
|
+
it('is safe when user not blocked (arrayRemove is idempotent)', async () => {
|
|
241
|
+
mockSetDocument.mockResolvedValue(undefined);
|
|
236
242
|
|
|
237
243
|
await unblockUser('owner-1', 'not-blocked');
|
|
238
244
|
|
|
239
|
-
|
|
245
|
+
// arrayRemove always writes — safe no-op if element not present
|
|
246
|
+
expect(mockSetDocument).toHaveBeenCalledTimes(1);
|
|
240
247
|
});
|
|
241
248
|
});
|
|
242
249
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* See agent/design/local.ghost-persona-system.md
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { getDocument, setDocument } from '../firestore/init.js';
|
|
12
|
+
import { getDocument, setDocument, FieldValue } from '../firestore/init.js';
|
|
13
13
|
import { BASE } from '../firestore/paths.js';
|
|
14
14
|
import { logger } from '../utils/logger.js';
|
|
15
15
|
import type { GhostConfig, TrustEnforcementMode } from '../types/ghost-config.js';
|
|
@@ -121,19 +121,16 @@ export async function removeUserTrust(
|
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Block a user from ghost access.
|
|
124
|
+
* Uses FieldValue.arrayUnion for atomic add without reading first (idempotent).
|
|
124
125
|
*/
|
|
125
126
|
export async function blockUser(
|
|
126
127
|
ownerUserId: string,
|
|
127
128
|
targetUserId: string
|
|
128
129
|
): Promise<void> {
|
|
129
|
-
const current = await getGhostConfig(ownerUserId);
|
|
130
|
-
if (current.blocked_users.includes(targetUserId)) {
|
|
131
|
-
return; // already blocked
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const blocked_users = [...current.blocked_users, targetUserId];
|
|
135
130
|
const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
|
|
136
|
-
await setDocument(collectionPath, docId, {
|
|
131
|
+
await setDocument(collectionPath, docId, {
|
|
132
|
+
blocked_users: FieldValue.arrayUnion(targetUserId),
|
|
133
|
+
}, { merge: true });
|
|
137
134
|
|
|
138
135
|
logger.info('User blocked from ghost access', {
|
|
139
136
|
service: SERVICE,
|
|
@@ -144,19 +141,16 @@ export async function blockUser(
|
|
|
144
141
|
|
|
145
142
|
/**
|
|
146
143
|
* Unblock a user from ghost access.
|
|
144
|
+
* Uses FieldValue.arrayRemove for atomic remove without reading first (safe if not present).
|
|
147
145
|
*/
|
|
148
146
|
export async function unblockUser(
|
|
149
147
|
ownerUserId: string,
|
|
150
148
|
targetUserId: string
|
|
151
149
|
): Promise<void> {
|
|
152
|
-
const current = await getGhostConfig(ownerUserId);
|
|
153
|
-
if (!current.blocked_users.includes(targetUserId)) {
|
|
154
|
-
return; // not blocked
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const blocked_users = current.blocked_users.filter(id => id !== targetUserId);
|
|
158
150
|
const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
|
|
159
|
-
await setDocument(collectionPath, docId, {
|
|
151
|
+
await setDocument(collectionPath, docId, {
|
|
152
|
+
blocked_users: FieldValue.arrayRemove(targetUserId),
|
|
153
|
+
}, { merge: true });
|
|
160
154
|
|
|
161
155
|
logger.info('User unblocked from ghost access', {
|
|
162
156
|
service: SERVICE,
|
|
@@ -181,10 +181,11 @@ export async function handleSearchMemory(
|
|
|
181
181
|
// Combine deleted filter, trust filter, ghost exclusion, and search filters
|
|
182
182
|
const combinedFilters = combineFiltersWithAnd([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter(f => f !== null));
|
|
183
183
|
|
|
184
|
-
// Build search options
|
|
184
|
+
// Build search options (native offset handled by Weaviate)
|
|
185
185
|
const searchOptions: any = {
|
|
186
186
|
alpha: alpha,
|
|
187
|
-
limit: limit
|
|
187
|
+
limit: limit,
|
|
188
|
+
offset: offset,
|
|
188
189
|
};
|
|
189
190
|
|
|
190
191
|
// Add filters if present
|
|
@@ -203,14 +204,11 @@ export async function handleSearchMemory(
|
|
|
203
204
|
// Perform hybrid search with Weaviate v3 API
|
|
204
205
|
const results = await collection.query.hybrid(args.query, searchOptions);
|
|
205
206
|
|
|
206
|
-
// Apply offset
|
|
207
|
-
const paginatedResults = results.objects.slice(offset);
|
|
208
|
-
|
|
209
207
|
// Separate memories and relationships
|
|
210
208
|
const memories: Partial<Memory>[] = [];
|
|
211
209
|
const relationships: Partial<Relationship>[] = [];
|
|
212
210
|
|
|
213
|
-
for (const obj of
|
|
211
|
+
for (const obj of results.objects) {
|
|
214
212
|
const doc: any = {
|
|
215
213
|
id: obj.uuid,
|
|
216
214
|
...obj.properties,
|