@prmichaelsen/remember-mcp 3.14.11 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/agent/milestones/milestone-18-performance-tuning.md +45 -0
  3. package/agent/progress.yaml +86 -2
  4. package/agent/tasks/task-77-parallelize-checkiffriend.md +62 -0
  5. package/agent/tasks/task-78-cache-checkiffriend.md +78 -0
  6. package/agent/tasks/task-79-memoize-core-services.md +75 -0
  7. package/agent/tasks/task-80-parallelize-startup-health-checks.md +57 -0
  8. package/agent/tasks/task-81-optimize-ghost-config-block-unblock.md +64 -0
  9. package/agent/tasks/task-82-native-weaviate-offset.md +69 -0
  10. package/agent/tasks/task-83-eliminate-redundant-validatetoken.md +53 -0
  11. package/agent/tasks/task-84-static-imports-server-factory.md +52 -0
  12. package/dist/core-services.d.ts +3 -1
  13. package/dist/server-factory.js +250 -482
  14. package/dist/server.js +29 -140
  15. package/dist/services/access-control.d.ts +5 -5
  16. package/dist/services/ghost-config.service.d.ts +2 -0
  17. package/package.json +1 -1
  18. package/src/core-services.ts +16 -2
  19. package/src/server-factory.ts +5 -3
  20. package/src/server.ts +5 -3
  21. package/src/services/access-control.spec.ts +11 -11
  22. package/src/services/access-control.ts +74 -8
  23. package/src/services/ghost-config.service.spec.ts +22 -15
  24. package/src/services/ghost-config.service.ts +9 -15
  25. package/src/tools/query-memory.ts +1 -2
  26. package/src/tools/search-memory.ts +5 -8
  27. package/dist/services/trust-enforcement.d.ts +0 -83
  28. package/dist/services/trust-enforcement.spec.d.ts +0 -2
  29. package/dist/utils/weaviate-filters.d.ts +0 -56
  30. package/dist/utils/weaviate-filters.spec.d.ts +0 -8
  31. package/src/services/trust-enforcement.spec.ts +0 -309
  32. package/src/services/trust-enforcement.ts +0 -197
  33. package/src/utils/weaviate-filters.spec.ts +0 -312
  34. package/src/utils/weaviate-filters.ts +0 -236
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
- return {
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
@@ -3815,118 +3821,6 @@ async function handleCreateMemory(args, userId, authContext, context) {
3815
3821
 
3816
3822
  // src/tools/search-memory.ts
3817
3823
  init_logger();
3818
-
3819
- // src/utils/weaviate-filters.ts
3820
- import { Filters as Filters6 } from "weaviate-client";
3821
- function buildCombinedSearchFilters2(collection, filters) {
3822
- const memoryFilters = buildDocTypeFilters2(collection, "memory", filters);
3823
- const relationshipFilters = buildDocTypeFilters2(collection, "relationship", filters);
3824
- const validFilters = [memoryFilters, relationshipFilters].filter((f) => f !== void 0 && f !== null);
3825
- if (validFilters.length === 0) {
3826
- return void 0;
3827
- } else if (validFilters.length === 1) {
3828
- return validFilters[0];
3829
- } else {
3830
- return combineFiltersWithOr2(validFilters);
3831
- }
3832
- }
3833
- function buildDocTypeFilters2(collection, docType, filters) {
3834
- const filterList = [];
3835
- filterList.push(
3836
- collection.filter.byProperty("doc_type").equal(docType)
3837
- );
3838
- if (docType === "memory" && filters?.types && filters.types.length > 0) {
3839
- if (filters.types.length === 1) {
3840
- filterList.push(
3841
- collection.filter.byProperty("content_type").equal(filters.types[0])
3842
- );
3843
- } else {
3844
- filterList.push(
3845
- collection.filter.byProperty("content_type").containsAny(filters.types)
3846
- );
3847
- }
3848
- }
3849
- if (filters?.weight_min !== void 0) {
3850
- filterList.push(
3851
- collection.filter.byProperty("weight").greaterThanOrEqual(filters.weight_min)
3852
- );
3853
- }
3854
- if (filters?.weight_max !== void 0) {
3855
- filterList.push(
3856
- collection.filter.byProperty("weight").lessThanOrEqual(filters.weight_max)
3857
- );
3858
- }
3859
- if (filters?.trust_min !== void 0) {
3860
- filterList.push(
3861
- collection.filter.byProperty("trust_score").greaterThanOrEqual(filters.trust_min)
3862
- );
3863
- }
3864
- if (filters?.trust_max !== void 0) {
3865
- filterList.push(
3866
- collection.filter.byProperty("trust_score").lessThanOrEqual(filters.trust_max)
3867
- );
3868
- }
3869
- if (filters?.date_from) {
3870
- filterList.push(
3871
- collection.filter.byProperty("created_at").greaterThanOrEqual(new Date(filters.date_from))
3872
- );
3873
- }
3874
- if (filters?.date_to) {
3875
- filterList.push(
3876
- collection.filter.byProperty("created_at").lessThanOrEqual(new Date(filters.date_to))
3877
- );
3878
- }
3879
- if (filters?.tags && filters.tags.length > 0) {
3880
- if (filters.tags.length === 1) {
3881
- filterList.push(
3882
- collection.filter.byProperty("tags").containsAny([filters.tags[0]])
3883
- );
3884
- } else {
3885
- filterList.push(
3886
- collection.filter.byProperty("tags").containsAny(filters.tags)
3887
- );
3888
- }
3889
- }
3890
- return combineFiltersWithAnd2(filterList);
3891
- }
3892
- function buildMemoryOnlyFilters2(collection, filters) {
3893
- return buildDocTypeFilters2(collection, "memory", filters);
3894
- }
3895
- function combineFiltersWithAnd2(filters) {
3896
- const validFilters = filters.filter((f) => f !== void 0 && f !== null);
3897
- if (validFilters.length === 0) {
3898
- return void 0;
3899
- }
3900
- if (validFilters.length === 1) {
3901
- return validFilters[0];
3902
- }
3903
- return Filters6.and(...validFilters);
3904
- }
3905
- function combineFiltersWithOr2(filters) {
3906
- const validFilters = filters.filter((f) => f !== void 0 && f !== null);
3907
- if (validFilters.length === 0) {
3908
- return void 0;
3909
- }
3910
- if (validFilters.length === 1) {
3911
- return validFilters[0];
3912
- }
3913
- return Filters6.or(...validFilters);
3914
- }
3915
- function buildDeletedFilter2(collection, deletedFilter = "exclude") {
3916
- if (deletedFilter === "exclude") {
3917
- return collection.filter.byProperty("deleted_at").isNull(true);
3918
- } else if (deletedFilter === "only") {
3919
- return collection.filter.byProperty("deleted_at").isNull(false);
3920
- }
3921
- return null;
3922
- }
3923
-
3924
- // src/services/trust-enforcement.ts
3925
- function buildTrustFilter2(collection, accessorTrustLevel) {
3926
- return collection.filter.byProperty("trust_score").lessThanOrEqual(accessorTrustLevel);
3927
- }
3928
-
3929
- // src/tools/search-memory.ts
3930
3824
  var searchMemoryTool = {
3931
3825
  name: "remember_search_memory",
3932
3826
  description: `Search memories AND relationships using hybrid semantic and keyword search.
@@ -4057,16 +3951,16 @@ async function handleSearchMemory(args, userId, authContext) {
4057
3951
  const alpha = args.alpha ?? 0.7;
4058
3952
  const limit = args.limit ?? 10;
4059
3953
  const offset = args.offset ?? 0;
4060
- const deletedFilter = buildDeletedFilter2(collection, args.deleted_filter || "exclude");
4061
- const trustFilter = ghostMode ? buildTrustFilter2(collection, ghostMode.accessor_trust_level) : null;
4062
- const searchFilters = includeRelationships ? buildCombinedSearchFilters2(collection, args.filters) : buildMemoryOnlyFilters2(collection, args.filters);
3954
+ const deletedFilter = buildDeletedFilter(collection, args.deleted_filter || "exclude");
3955
+ const trustFilter = ghostMode ? buildTrustFilter(collection, ghostMode.accessor_trust_level) : null;
3956
+ const searchFilters = includeRelationships ? buildCombinedSearchFilters(collection, args.filters) : buildMemoryOnlyFilters(collection, args.filters);
4063
3957
  const hasExplicitTypeFilter = args.filters?.types && args.filters.types.length > 0;
4064
3958
  const ghostExclusionFilter = !hasExplicitTypeFilter ? collection.filter.byProperty("content_type").notEqual("ghost") : null;
4065
- const combinedFilters = combineFiltersWithAnd2([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter((f) => f !== null));
3959
+ const combinedFilters = combineFiltersWithAnd([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter((f) => f !== null));
4066
3960
  const searchOptions = {
4067
3961
  alpha,
4068
- limit: limit + offset
4069
- // Get extra for offset
3962
+ limit,
3963
+ offset
4070
3964
  };
4071
3965
  if (combinedFilters) {
4072
3966
  searchOptions.filters = combinedFilters;
@@ -4078,10 +3972,9 @@ async function handleSearchMemory(args, userId, authContext) {
4078
3972
  deletedFilter: args.deleted_filter || "exclude"
4079
3973
  });
4080
3974
  const results = await collection.query.hybrid(args.query, searchOptions);
4081
- const paginatedResults = results.objects.slice(offset);
4082
3975
  const memories = [];
4083
3976
  const relationships = [];
4084
- for (const obj of paginatedResults) {
3977
+ for (const obj of results.objects) {
4085
3978
  const doc = {
4086
3979
  id: obj.uuid,
4087
3980
  ...obj.properties
@@ -4524,12 +4417,12 @@ async function handleQueryMemory(args, userId, authContext) {
4524
4417
  const minRelevance = args.min_relevance ?? 0.6;
4525
4418
  const includeContext = args.include_context ?? true;
4526
4419
  const format = args.format ?? "detailed";
4527
- const deletedFilter = buildDeletedFilter2(collection, args.deleted_filter || "exclude");
4528
- const trustFilter = ghostMode ? buildTrustFilter2(collection, ghostMode.accessor_trust_level) : null;
4529
- const searchFilters = buildCombinedSearchFilters2(collection, args.filters);
4420
+ const deletedFilter = buildDeletedFilter(collection, args.deleted_filter || "exclude");
4421
+ const trustFilter = ghostMode ? buildTrustFilter(collection, ghostMode.accessor_trust_level) : null;
4422
+ const searchFilters = buildCombinedSearchFilters(collection, args.filters);
4530
4423
  const hasExplicitTypeFilter = args.filters?.types && args.filters.types.length > 0;
4531
4424
  const ghostExclusionFilter = !hasExplicitTypeFilter ? collection.filter.byProperty("content_type").notEqual("ghost") : null;
4532
- const combinedFilters = combineFiltersWithAnd2([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter((f) => f !== null));
4425
+ const combinedFilters = combineFiltersWithAnd([deletedFilter, trustFilter, ghostExclusionFilter, searchFilters].filter((f) => f !== null));
4533
4426
  const searchOptions = {
4534
4427
  limit,
4535
4428
  distance: 1 - minRelevance,
@@ -5641,7 +5534,7 @@ async function handleDeny(args, userId, authContext) {
5641
5534
  }
5642
5535
 
5643
5536
  // src/tools/search-space.ts
5644
- import { Filters as Filters7 } from "weaviate-client";
5537
+ import { Filters as Filters6 } from "weaviate-client";
5645
5538
  var searchSpaceTool = {
5646
5539
  name: "remember_search_space",
5647
5540
  description: `Search shared spaces and/or groups to discover memories from other users.
@@ -6125,13 +6018,10 @@ async function removeUserTrust2(ownerUserId, targetUserId) {
6125
6018
  });
6126
6019
  }
6127
6020
  async function blockUser2(ownerUserId, targetUserId) {
6128
- const current = await getGhostConfig2(ownerUserId);
6129
- if (current.blocked_users.includes(targetUserId)) {
6130
- return;
6131
- }
6132
- const blocked_users = [...current.blocked_users, targetUserId];
6133
6021
  const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
6134
- await setDocument(collectionPath, docId, { blocked_users }, { merge: true });
6022
+ await setDocument(collectionPath, docId, {
6023
+ blocked_users: FieldValue.arrayUnion(targetUserId)
6024
+ }, { merge: true });
6135
6025
  logger.info("User blocked from ghost access", {
6136
6026
  service: SERVICE,
6137
6027
  ownerUserId,
@@ -6139,13 +6029,10 @@ async function blockUser2(ownerUserId, targetUserId) {
6139
6029
  });
6140
6030
  }
6141
6031
  async function unblockUser2(ownerUserId, targetUserId) {
6142
- const current = await getGhostConfig2(ownerUserId);
6143
- if (!current.blocked_users.includes(targetUserId)) {
6144
- return;
6145
- }
6146
- const blocked_users = current.blocked_users.filter((id) => id !== targetUserId);
6147
6032
  const { collectionPath, docId } = getGhostConfigPath(ownerUserId);
6148
- await setDocument(collectionPath, docId, { blocked_users }, { merge: true });
6033
+ await setDocument(collectionPath, docId, {
6034
+ blocked_users: FieldValue.arrayRemove(targetUserId)
6035
+ }, { merge: true });
6149
6036
  logger.info("User unblocked from ghost access", {
6150
6037
  service: SERVICE,
6151
6038
  ownerUserId,
@@ -6361,8 +6248,10 @@ async function initServer() {
6361
6248
  logger.info("Connecting to databases...");
6362
6249
  await initWeaviateClient();
6363
6250
  initFirestore();
6364
- const weaviateOk = await testWeaviateConnection();
6365
- const firestoreOk = await testFirestoreConnection();
6251
+ const [weaviateOk, firestoreOk] = await Promise.all([
6252
+ testWeaviateConnection(),
6253
+ testFirestoreConnection()
6254
+ ]);
6366
6255
  if (!weaviateOk || !firestoreOk) {
6367
6256
  throw new Error("Database connection failed");
6368
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
- * 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.11",
3
+ "version": "3.14.14",
4
4
  "description": "Multi-tenant memory system MCP server with vector search and relationships",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",
@@ -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
 
@@ -16,7 +16,7 @@ import type { Memory } from '../types/memory.js';
16
16
  import type { AccessResult } from '../types/access-result.js';
17
17
  import type { GhostConfig } from '../types/ghost-config.js';
18
18
  import { DEFAULT_GHOST_CONFIG } from '../types/ghost-config.js';
19
- import { isTrustSufficient } from './trust-enforcement.js';
19
+ import { isTrustSufficient } from '@prmichaelsen/remember-core';
20
20
 
21
21
  // ─── Types ─────────────────────────────────────────────────────────────────
22
22
 
@@ -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