@prmichaelsen/remember-mcp 3.14.12 → 3.14.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -855,24 +855,7 @@ var DEFAULT_PREFERENCES = {
855
855
  var SUPPORTED_SPACES = [
856
856
  "the_void",
857
857
  "profiles",
858
- "spaces",
859
- "ghosts",
860
- "poems",
861
- "recipes",
862
- "quotes",
863
- "dreams",
864
- "travel",
865
- "music",
866
- "pets",
867
- "books",
868
- "funny",
869
- "ideas",
870
- "art",
871
- "fitness",
872
- "how_to",
873
- "movies",
874
- "nature",
875
- "journal"
858
+ "ghosts"
876
859
  ];
877
860
  var SPACE_CONTENT_TYPE_RESTRICTIONS = {
878
861
  profiles: "profile",
@@ -1561,6 +1544,12 @@ function buildDocTypeFilters(collection, docType, filters) {
1561
1544
  if (filters?.date_to) {
1562
1545
  filterList.push(collection.filter.byProperty("created_at").lessOrEqual(new Date(filters.date_to)));
1563
1546
  }
1547
+ if (filters?.relationship_count_min !== void 0) {
1548
+ filterList.push(collection.filter.byProperty("relationship_count").greaterOrEqual(filters.relationship_count_min));
1549
+ }
1550
+ if (filters?.relationship_count_max !== void 0) {
1551
+ filterList.push(collection.filter.byProperty("relationship_count").lessOrEqual(filters.relationship_count_max));
1552
+ }
1564
1553
  if (filters?.tags && filters.tags.length > 0) {
1565
1554
  filterList.push(collection.filter.byProperty("tags").containsAny(filters.tags));
1566
1555
  }
@@ -1731,7 +1720,7 @@ var PreferencesDatabaseService = class {
1731
1720
  };
1732
1721
 
1733
1722
  // node_modules/@prmichaelsen/remember-core/dist/services/confirmation-token.service.js
1734
- import { randomUUID } from "crypto";
1723
+ var randomUUID = () => globalThis.crypto.randomUUID();
1735
1724
  var ConfirmationTokenService = class {
1736
1725
  EXPIRY_MINUTES = 5;
1737
1726
  logger;
@@ -2107,12 +2096,7 @@ var MemoryService = class {
2107
2096
  const combinedFilters = combineFiltersWithAnd([deletedFilter, memoryFilters, ...ghostFilters].filter((f) => f !== null));
2108
2097
  const queryOptions = {
2109
2098
  limit: limit + offset,
2110
- sort: [
2111
- {
2112
- property: "created_at",
2113
- order: direction
2114
- }
2115
- ]
2099
+ sort: this.collection.sort.byProperty("created_at", direction === "asc")
2116
2100
  };
2117
2101
  if (combinedFilters) {
2118
2102
  queryOptions.filters = combinedFilters;
@@ -2156,13 +2140,7 @@ var MemoryService = class {
2156
2140
  const combinedFilters = combineFiltersWithAnd([deletedFilter, memoryFilters, ...ghostFilters, ...densityFilters].filter((f) => f !== null));
2157
2141
  const queryOptions = {
2158
2142
  limit: limit + offset,
2159
- sort: [
2160
- {
2161
- property: "relationship_count",
2162
- order: "desc"
2163
- // Highest first
2164
- }
2165
- ]
2143
+ sort: this.collection.sort.byProperty("relationship_count", false)
2166
2144
  };
2167
2145
  if (combinedFilters) {
2168
2146
  queryOptions.filters = combinedFilters;
@@ -2664,6 +2642,18 @@ async function registerCollection(entry) {
2664
2642
  }
2665
2643
 
2666
2644
  // node_modules/@prmichaelsen/remember-core/dist/database/weaviate/v2-collections.js
2645
+ var collectionCache = /* @__PURE__ */ new Map();
2646
+ var COLLECTION_CACHE_TTL_MS = 6e4;
2647
+ function isCollectionCached(collectionName) {
2648
+ const expiresAt = collectionCache.get(collectionName);
2649
+ if (expiresAt !== void 0 && Date.now() < expiresAt)
2650
+ return true;
2651
+ collectionCache.delete(collectionName);
2652
+ return false;
2653
+ }
2654
+ function cacheCollection(collectionName) {
2655
+ collectionCache.set(collectionName, Date.now() + COLLECTION_CACHE_TTL_MS);
2656
+ }
2667
2657
  var COMMON_MEMORY_PROPERTIES = [
2668
2658
  // Core content
2669
2659
  { name: "content", dataType: configure.dataType.TEXT },
@@ -2822,9 +2812,12 @@ async function reconcileCollectionProperties(client2, collectionName, expectedPr
2822
2812
  }
2823
2813
  async function ensureGroupCollection(client2, groupId) {
2824
2814
  const collectionName = `Memory_groups_${groupId}`;
2815
+ if (isCollectionCached(collectionName))
2816
+ return false;
2825
2817
  const exists = await client2.collections.exists(collectionName);
2826
2818
  if (exists) {
2827
2819
  await reconcileCollectionProperties(client2, collectionName, [...COMMON_MEMORY_PROPERTIES, ...PUBLISHED_MEMORY_PROPERTIES]);
2820
+ cacheCollection(collectionName);
2828
2821
  return false;
2829
2822
  }
2830
2823
  const schema = createGroupCollectionSchema(groupId);
@@ -2835,6 +2828,7 @@ async function ensureGroupCollection(client2, groupId) {
2835
2828
  owner_id: groupId,
2836
2829
  created_at: (/* @__PURE__ */ new Date()).toISOString()
2837
2830
  });
2831
+ cacheCollection(collectionName);
2838
2832
  return true;
2839
2833
  }
2840
2834
 
@@ -2845,10 +2839,13 @@ function isValidSpaceId(spaceId) {
2845
2839
  }
2846
2840
  async function ensurePublicCollection(client2) {
2847
2841
  const collectionName = PUBLIC_COLLECTION_NAME;
2848
- const exists = await client2.collections.exists(collectionName);
2849
- if (!exists) {
2850
- const schema = createSpaceCollectionSchema();
2851
- await client2.collections.create(schema);
2842
+ if (!isCollectionCached(collectionName)) {
2843
+ const exists = await client2.collections.exists(collectionName);
2844
+ if (!exists) {
2845
+ const schema = createSpaceCollectionSchema();
2846
+ await client2.collections.create(schema);
2847
+ }
2848
+ cacheCollection(collectionName);
2852
2849
  }
2853
2850
  return client2.collections.get(collectionName);
2854
2851
  }
@@ -3676,16 +3673,22 @@ function getMemoryCollection(userId) {
3676
3673
  var coreLogger = createLogger("info");
3677
3674
  var tokenService = new ConfirmationTokenService(coreLogger);
3678
3675
  var preferencesService = new PreferencesDatabaseService(coreLogger);
3676
+ var coreServicesCache = /* @__PURE__ */ new Map();
3679
3677
  function createCoreServices(userId) {
3678
+ const cached = coreServicesCache.get(userId);
3679
+ if (cached)
3680
+ return cached;
3680
3681
  const collection = getMemoryCollection(userId);
3681
3682
  const weaviateClient = getWeaviateClient();
3682
- return {
3683
+ const services = {
3683
3684
  memory: new MemoryService(collection, userId, coreLogger),
3684
3685
  relationship: new RelationshipService(collection, userId, coreLogger),
3685
3686
  space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger),
3686
3687
  preferences: preferencesService,
3687
3688
  token: tokenService
3688
3689
  };
3690
+ coreServicesCache.set(userId, services);
3691
+ return services;
3689
3692
  }
3690
3693
 
3691
3694
  // src/tools/create-memory.ts
@@ -3953,8 +3956,8 @@ async function handleSearchMemory(args, userId, authContext) {
3953
3956
  const combinedFilters = combineFiltersWithAnd([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter((f) => f !== null));
3954
3957
  const searchOptions = {
3955
3958
  alpha,
3956
- limit: limit + offset
3957
- // Get extra for offset
3959
+ limit,
3960
+ offset
3958
3961
  };
3959
3962
  if (combinedFilters) {
3960
3963
  searchOptions.filters = combinedFilters;
@@ -3966,10 +3969,9 @@ async function handleSearchMemory(args, userId, authContext) {
3966
3969
  deletedFilter: args.deleted_filter || "exclude"
3967
3970
  });
3968
3971
  const results = await collection.query.hybrid(args.query, searchOptions);
3969
- const paginatedResults = results.objects.slice(offset);
3970
3972
  const memories = [];
3971
3973
  const relationships = [];
3972
- for (const obj of paginatedResults) {
3974
+ for (const obj of results.objects) {
3973
3975
  const doc = {
3974
3976
  id: obj.uuid,
3975
3977
  ...obj.properties
@@ -6013,13 +6015,10 @@ async function removeUserTrust2(ownerUserId, targetUserId) {
6013
6015
  });
6014
6016
  }
6015
6017
  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
6018
  const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
6022
- await setDocument(collectionPath, docId, { blocked_users }, { merge: true });
6019
+ await setDocument(collectionPath, docId, {
6020
+ blocked_users: FieldValue.arrayUnion(targetUserId)
6021
+ }, { merge: true });
6023
6022
  logger.info("User blocked from ghost access", {
6024
6023
  service: SERVICE,
6025
6024
  ownerUserId,
@@ -6027,13 +6026,10 @@ async function blockUser2(ownerUserId, targetUserId) {
6027
6026
  });
6028
6027
  }
6029
6028
  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
6029
  const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
6036
- await setDocument(collectionPath, docId, { blocked_users }, { merge: true });
6030
+ await setDocument(collectionPath, docId, {
6031
+ blocked_users: FieldValue.arrayRemove(targetUserId)
6032
+ }, { merge: true });
6037
6033
  logger.info("User unblocked from ghost access", {
6038
6034
  service: SERVICE,
6039
6035
  ownerUserId,
@@ -6249,8 +6245,10 @@ async function initServer() {
6249
6245
  logger.info("Connecting to databases...");
6250
6246
  await initWeaviateClient();
6251
6247
  initFirestore();
6252
- const weaviateOk = await testWeaviateConnection();
6253
- const firestoreOk = await testFirestoreConnection();
6248
+ const [weaviateOk, firestoreOk] = await Promise.all([
6249
+ testWeaviateConnection(),
6250
+ testFirestoreConnection()
6251
+ ]);
6254
6252
  if (!weaviateOk || !firestoreOk) {
6255
6253
  throw new Error("Database connection failed");
6256
6254
  }
@@ -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
- * Note: "friend" vs "public" distinction will be determined by the calling
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/remember-mcp",
3
- "version": "3.14.12",
3
+ "version": "3.14.15",
4
4
  "description": "Multi-tenant memory system MCP server with vector search and relationships",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",
@@ -50,7 +50,7 @@
50
50
  "@modelcontextprotocol/sdk": "^1.0.4",
51
51
  "@prmichaelsen/firebase-admin-sdk-v8": "^2.2.0",
52
52
  "@prmichaelsen/mcp-auth": "^7.0.4",
53
- "@prmichaelsen/remember-core": "^0.22.5",
53
+ "@prmichaelsen/remember-core": "^0.24.2",
54
54
  "dotenv": "^16.4.5",
55
55
  "uuid": "^13.0.0",
56
56
  "weaviate-client": "^3.2.0"
@@ -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
- return {
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 };
@@ -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 testWeaviateConnection();
62
- const firestoreOk = await testFirestoreConnection();
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
- * Note: "friend" vs "public" distinction will be determined by the calling
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(ghostConfig: GhostConfig, accessorUserId: string): number {
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. Fall through to public trust (friend detection deferred to M16)
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
- mockGetDocument.mockResolvedValue({ blocked_users: ['bad-user'] });
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
- expect(mockSetDocument).not.toHaveBeenCalled();
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: ['user-a', 'user-b'] },
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: ['other'] },
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('does nothing if user not blocked', async () => {
235
- mockGetDocument.mockResolvedValue({ blocked_users: [] });
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
- expect(mockSetDocument).not.toHaveBeenCalled();
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, { blocked_users }, { merge: true });
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, { blocked_users }, { merge: true });
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,