@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/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 +2662 -3186
- package/dist/server.js +53 -55
- package/dist/services/access-control.d.ts +5 -5
- package/dist/services/ghost-config.service.d.ts +2 -0
- package/package.json +2 -2
- 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
|
@@ -855,24 +855,7 @@ var DEFAULT_PREFERENCES = {
|
|
|
855
855
|
var SUPPORTED_SPACES = [
|
|
856
856
|
"the_void",
|
|
857
857
|
"profiles",
|
|
858
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
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
|
-
|
|
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
|
|
3957
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
6253
|
-
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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"
|
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,
|