@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/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
@@ -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: limit + offset
3957
- // Get extra for offset
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 paginatedResults) {
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, { blocked_users }, { merge: true });
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, { blocked_users }, { merge: true });
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 testWeaviateConnection();
6253
- const firestoreOk = await testFirestoreConnection();
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
- * 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.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
 
@@ -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,
@@ -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 + offset, // Get extra for offset
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 paginatedResults) {
211
+ for (const obj of results.objects) {
214
212
  const doc: any = {
215
213
  id: obj.uuid,
216
214
  ...obj.properties,