fraim 2.0.100 → 2.0.102
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/README.md +39 -17
- package/bin/fraim.js +1 -1
- package/dist/src/cli/commands/init-project.js +6 -2
- package/dist/src/cli/commands/setup.js +1 -1
- package/dist/src/cli/commands/sync.js +12 -0
- package/dist/src/cli/services/device-flow-service.js +83 -0
- package/dist/src/cli/utils/agent-adapters.js +23 -3
- package/dist/src/cli/utils/fraim-gitignore.js +66 -15
- package/dist/src/cli/utils/version-utils.js +10 -7
- package/dist/src/core/config-loader.js +18 -0
- package/dist/src/core/utils/project-fraim-migration.js +12 -0
- package/dist/src/core/utils/workflow-parser.js +5 -3
- package/dist/src/local-mcp-server/stdio-server.js +298 -23
- package/dist/src/local-mcp-server/usage-collector.js +62 -51
- package/index.js +84 -84
- package/package.json +7 -2
|
@@ -81,7 +81,12 @@ class FraimTemplateEngine {
|
|
|
81
81
|
return this.userEmail;
|
|
82
82
|
}
|
|
83
83
|
substituteTemplates(content) {
|
|
84
|
+
return this.substituteTemplatesWithNotices(content).content;
|
|
85
|
+
}
|
|
86
|
+
substituteTemplatesWithNotices(content) {
|
|
84
87
|
let result = content;
|
|
88
|
+
const notices = [];
|
|
89
|
+
const blockingRequirements = [];
|
|
85
90
|
// Substitute {{proxy.user.email}} with the email captured from fraim_connect
|
|
86
91
|
if (this.userEmail) {
|
|
87
92
|
result = result.replace(/\{\{proxy\.user\.email\}\}/g, this.userEmail);
|
|
@@ -103,22 +108,17 @@ class FraimTemplateEngine {
|
|
|
103
108
|
return match;
|
|
104
109
|
});
|
|
105
110
|
// First, substitute config variables with fallback support.
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return fallback !== undefined ? fallback : match;
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
return fallback !== undefined ? fallback : match;
|
|
121
|
-
}
|
|
111
|
+
// Supported forms:
|
|
112
|
+
// {{proxy.config.path}}
|
|
113
|
+
// {{proxy.config.path | "fallback"}}
|
|
114
|
+
// {{proxy.config.path | INFORM | "fallback behavior"}}
|
|
115
|
+
// {{proxy.config.path | NO_OP | "fallback behavior"}}
|
|
116
|
+
// {{proxy.config.path | REQUIRE | "fallback behavior"}}
|
|
117
|
+
// Note: \s* tolerates optional whitespace inside {{...}} (e.g. from remote server formatting).
|
|
118
|
+
// This must match the same set of templates that rewriteProxyTokensInText can rewrite,
|
|
119
|
+
// so that config templates are always resolved here rather than falling through to the fallback rewriter.
|
|
120
|
+
result = result.replace(/\{\{\s*proxy\.config\.([^}]+?)\s*\}\}/g, (match, expression) => {
|
|
121
|
+
return this.resolveConfigTemplate(match, expression, notices, blockingRequirements);
|
|
122
122
|
});
|
|
123
123
|
// Second, substitute {{proxy.delivery.*}} templates
|
|
124
124
|
const deliveryValues = this.loadDeliveryTemplates();
|
|
@@ -130,7 +130,130 @@ class FraimTemplateEngine {
|
|
|
130
130
|
}
|
|
131
131
|
// Third, substitute platform-specific action templates
|
|
132
132
|
result = this.substitutePlatformActions(result);
|
|
133
|
-
return
|
|
133
|
+
return {
|
|
134
|
+
content: result,
|
|
135
|
+
notices,
|
|
136
|
+
blockingRequirements
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
resolveConfigTemplate(originalMatch, expression, notices, blockingRequirements) {
|
|
140
|
+
const segments = this.splitTemplateSegments(expression);
|
|
141
|
+
if (segments.length === 0)
|
|
142
|
+
return originalMatch;
|
|
143
|
+
const path = segments[0].trim();
|
|
144
|
+
const remainder = segments.slice(1).map(segment => segment.trim()).filter(Boolean);
|
|
145
|
+
let action = null;
|
|
146
|
+
let fallback;
|
|
147
|
+
if (remainder.length === 1) {
|
|
148
|
+
fallback = this.parseQuotedTemplateValue(remainder[0]);
|
|
149
|
+
if (fallback === undefined)
|
|
150
|
+
return originalMatch;
|
|
151
|
+
}
|
|
152
|
+
else if (remainder.length === 2) {
|
|
153
|
+
const candidateAction = remainder[0].toUpperCase();
|
|
154
|
+
if (!FraimTemplateEngine.CONFIG_TEMPLATE_ACTIONS.has(candidateAction)) {
|
|
155
|
+
return originalMatch;
|
|
156
|
+
}
|
|
157
|
+
fallback = this.parseQuotedTemplateValue(remainder[1]);
|
|
158
|
+
if (fallback === undefined)
|
|
159
|
+
return originalMatch;
|
|
160
|
+
action = candidateAction;
|
|
161
|
+
}
|
|
162
|
+
else if (remainder.length > 0) {
|
|
163
|
+
return originalMatch;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
if (this.config) {
|
|
167
|
+
const value = (0, object_utils_1.getNestedValue)(this.config, path);
|
|
168
|
+
if (value !== undefined) {
|
|
169
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (fallback === undefined) {
|
|
173
|
+
return originalMatch;
|
|
174
|
+
}
|
|
175
|
+
if (action === null || action === 'NO_OP') {
|
|
176
|
+
return fallback;
|
|
177
|
+
}
|
|
178
|
+
if (action === 'REQUIRE') {
|
|
179
|
+
blockingRequirements.push(this.buildMissingConfigNotice(path, action));
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
notices.push(this.buildMissingConfigNotice(path, action));
|
|
183
|
+
}
|
|
184
|
+
return fallback;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (fallback === undefined) {
|
|
188
|
+
return originalMatch;
|
|
189
|
+
}
|
|
190
|
+
if (action === null || action === 'NO_OP') {
|
|
191
|
+
return fallback;
|
|
192
|
+
}
|
|
193
|
+
if (action === 'REQUIRE') {
|
|
194
|
+
blockingRequirements.push(this.buildMissingConfigNotice(path, action));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
notices.push(this.buildMissingConfigNotice(path, action));
|
|
198
|
+
}
|
|
199
|
+
return fallback;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
splitTemplateSegments(expression) {
|
|
203
|
+
const segments = [];
|
|
204
|
+
let current = '';
|
|
205
|
+
let inQuotes = false;
|
|
206
|
+
for (let i = 0; i < expression.length; i += 1) {
|
|
207
|
+
const char = expression[i];
|
|
208
|
+
const prev = i > 0 ? expression[i - 1] : '';
|
|
209
|
+
if (char === '"' && prev !== '\\') {
|
|
210
|
+
inQuotes = !inQuotes;
|
|
211
|
+
current += char;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (char === '|' && !inQuotes) {
|
|
215
|
+
segments.push(current);
|
|
216
|
+
current = '';
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
current += char;
|
|
220
|
+
}
|
|
221
|
+
segments.push(current);
|
|
222
|
+
return segments;
|
|
223
|
+
}
|
|
224
|
+
parseQuotedTemplateValue(segment) {
|
|
225
|
+
const trimmed = segment.trim();
|
|
226
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(trimmed);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
buildMissingConfigNotice(path, action) {
|
|
237
|
+
const metadata = FraimTemplateEngine.CONFIG_CONTEXT_METADATA[path];
|
|
238
|
+
const label = metadata?.label || `config value \`${path}\``;
|
|
239
|
+
const onboardingHint = metadata?.onboardingHint || `provide \`${path}\` in \`fraim/config.json\``;
|
|
240
|
+
if (action === 'REQUIRE') {
|
|
241
|
+
return {
|
|
242
|
+
action,
|
|
243
|
+
path,
|
|
244
|
+
message: `${this.capitalizeFirst(label)} is required for this task but is not configured. Invoke the manager \`project-onboarding\` job and ${onboardingHint} before continuing.`
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
action,
|
|
249
|
+
path,
|
|
250
|
+
message: `${this.capitalizeFirst(label)} is not configured. Invoke the manager \`project-onboarding\` job and ${onboardingHint}.`
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
capitalizeFirst(value) {
|
|
254
|
+
if (!value)
|
|
255
|
+
return value;
|
|
256
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
134
257
|
}
|
|
135
258
|
loadDeliveryTemplates() {
|
|
136
259
|
if (this.deliveryTemplatesCache)
|
|
@@ -235,6 +358,25 @@ class FraimTemplateEngine {
|
|
|
235
358
|
}
|
|
236
359
|
}
|
|
237
360
|
exports.FraimTemplateEngine = FraimTemplateEngine;
|
|
361
|
+
FraimTemplateEngine.CONFIG_TEMPLATE_ACTIONS = new Set(['INFORM', 'NO_OP', 'REQUIRE']);
|
|
362
|
+
FraimTemplateEngine.CONFIG_CONTEXT_METADATA = {
|
|
363
|
+
'customizations.architectureDoc': {
|
|
364
|
+
label: 'project-specific architecture document',
|
|
365
|
+
onboardingHint: 'provide the architecture document path in `fraim/config.json`'
|
|
366
|
+
},
|
|
367
|
+
'customizations.designSystem.path': {
|
|
368
|
+
label: 'project-specific design system',
|
|
369
|
+
onboardingHint: 'provide the design system path in `fraim/config.json`'
|
|
370
|
+
},
|
|
371
|
+
compliance: {
|
|
372
|
+
label: 'project compliance configuration',
|
|
373
|
+
onboardingHint: 'record the project compliance requirements in `fraim/config.json`'
|
|
374
|
+
},
|
|
375
|
+
'compliance.regulations': {
|
|
376
|
+
label: 'project compliance regulations',
|
|
377
|
+
onboardingHint: 'record the applicable compliance regulations in `fraim/config.json`'
|
|
378
|
+
}
|
|
379
|
+
};
|
|
238
380
|
FraimTemplateEngine.PROXY_ACTION_PREFIX = 'proxy.action.';
|
|
239
381
|
FraimTemplateEngine.ISSUE_ACTIONS = new Set([
|
|
240
382
|
'get_issue',
|
|
@@ -798,6 +940,11 @@ class FraimLocalMCPServer {
|
|
|
798
940
|
await this.ensureProviderTemplatesAvailable(processedResponse, requestSessionId);
|
|
799
941
|
processedResponse = this.processResponse(processedResponse);
|
|
800
942
|
}
|
|
943
|
+
const blockingResponse = this.failForMissingRequiredConfig(processedResponse);
|
|
944
|
+
if (blockingResponse) {
|
|
945
|
+
return blockingResponse;
|
|
946
|
+
}
|
|
947
|
+
processedResponse = this.prependMissingConfigNotices(processedResponse);
|
|
801
948
|
return this.applyAgentFallbackForUnresolvedProxy(processedResponse);
|
|
802
949
|
}
|
|
803
950
|
async resolveIncludesInResponse(response, requestSessionId, requestId) {
|
|
@@ -856,12 +1003,11 @@ class FraimLocalMCPServer {
|
|
|
856
1003
|
// personalized-employee/ takes priority; when remote fails, resolver falls back to synced/cached content
|
|
857
1004
|
const resolved = await resolver.resolveFile(registryPath).catch(() => null);
|
|
858
1005
|
if (resolved?.content) {
|
|
859
|
-
const substitutedContent = this.substituteTemplates(resolved.content);
|
|
860
1006
|
const newResponse = {
|
|
861
1007
|
...response,
|
|
862
1008
|
result: {
|
|
863
1009
|
...response.result,
|
|
864
|
-
content: [{ type: 'text', text:
|
|
1010
|
+
content: [{ type: 'text', text: resolved.content }]
|
|
865
1011
|
}
|
|
866
1012
|
};
|
|
867
1013
|
delete newResponse.error;
|
|
@@ -925,12 +1071,24 @@ class FraimLocalMCPServer {
|
|
|
925
1071
|
const tokens = new Set();
|
|
926
1072
|
const rewritten = text.replace(/\{\{\s*proxy\.([^}]+?)\s*\}\}/g, (_match, proxyPath) => {
|
|
927
1073
|
const normalized = proxyPath.trim();
|
|
1074
|
+
// proxy.config.* must never reach here — substituteTemplatesWithNotices resolves all of
|
|
1075
|
+
// them (with config values or fallback text). If one slips through it is a bug; strip it
|
|
1076
|
+
// and log rather than rewriting to {{agent.config.*}} which confuses the agent (issue #210).
|
|
1077
|
+
if (normalized.startsWith('config.')) {
|
|
1078
|
+
this.logError(`[${FraimLocalMCPServer.FALLBACK_ALERT_MARKER}] BUG: unresolved proxy.${normalized} survived processResponse — stripping`);
|
|
1079
|
+
return '';
|
|
1080
|
+
}
|
|
928
1081
|
tokens.add(`proxy.${normalized}`);
|
|
929
1082
|
return `{{agent.${normalized}}}`;
|
|
930
1083
|
});
|
|
931
|
-
|
|
1084
|
+
// If nothing changed at all, return early (no allocation, no notice).
|
|
1085
|
+
if (rewritten === text) {
|
|
932
1086
|
return { text, tokens: [] };
|
|
933
1087
|
}
|
|
1088
|
+
// Only prepend the agent-resolution notice when there are actual agent tokens to resolve.
|
|
1089
|
+
if (tokens.size === 0) {
|
|
1090
|
+
return { text: rewritten, tokens: [] };
|
|
1091
|
+
}
|
|
934
1092
|
const hasResolutionNotice = rewritten.includes('## Agent Resolution Needed');
|
|
935
1093
|
const finalText = hasResolutionNotice
|
|
936
1094
|
? rewritten
|
|
@@ -970,6 +1128,84 @@ class FraimLocalMCPServer {
|
|
|
970
1128
|
substituteTemplates(content) {
|
|
971
1129
|
return this.ensureEngine().substituteTemplates(content);
|
|
972
1130
|
}
|
|
1131
|
+
prependMissingConfigNotices(response) {
|
|
1132
|
+
if (!response.result?.content || !Array.isArray(response.result.content)) {
|
|
1133
|
+
return response;
|
|
1134
|
+
}
|
|
1135
|
+
const contentBlocks = response.result.content;
|
|
1136
|
+
const firstTextIndex = contentBlocks.findIndex((block) => block?.type === 'text' && typeof block.text === 'string');
|
|
1137
|
+
if (firstTextIndex === -1) {
|
|
1138
|
+
return response;
|
|
1139
|
+
}
|
|
1140
|
+
const existingText = contentBlocks[firstTextIndex].text;
|
|
1141
|
+
const notices = response.result.__proxyMissingConfigNotices;
|
|
1142
|
+
if (!notices || notices.length === 0) {
|
|
1143
|
+
return response;
|
|
1144
|
+
}
|
|
1145
|
+
const uniqueNotices = [];
|
|
1146
|
+
const seen = new Set();
|
|
1147
|
+
for (const notice of notices) {
|
|
1148
|
+
const key = `${notice.action}:${notice.path}:${notice.message}`;
|
|
1149
|
+
if (seen.has(key))
|
|
1150
|
+
continue;
|
|
1151
|
+
seen.add(key);
|
|
1152
|
+
uniqueNotices.push(notice);
|
|
1153
|
+
}
|
|
1154
|
+
if (uniqueNotices.length === 0) {
|
|
1155
|
+
return response;
|
|
1156
|
+
}
|
|
1157
|
+
const noticeLines = ['## Missing Project Context'];
|
|
1158
|
+
for (const notice of uniqueNotices) {
|
|
1159
|
+
noticeLines.push(`- ${notice.message}`);
|
|
1160
|
+
}
|
|
1161
|
+
const prefix = `${noticeLines.join('\n')}\n\n---\n\n`;
|
|
1162
|
+
const transformedContent = contentBlocks.map((block, index) => {
|
|
1163
|
+
if (index !== firstTextIndex)
|
|
1164
|
+
return block;
|
|
1165
|
+
return {
|
|
1166
|
+
...block,
|
|
1167
|
+
text: prefix + existingText
|
|
1168
|
+
};
|
|
1169
|
+
});
|
|
1170
|
+
const { __proxyMissingConfigNotices, ...restResult } = response.result;
|
|
1171
|
+
return {
|
|
1172
|
+
...response,
|
|
1173
|
+
result: {
|
|
1174
|
+
...restResult,
|
|
1175
|
+
content: transformedContent
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
failForMissingRequiredConfig(response) {
|
|
1180
|
+
const requirements = response.result?.__proxyMissingConfigRequirements;
|
|
1181
|
+
if (!requirements || requirements.length === 0) {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
const uniqueRequirements = [];
|
|
1185
|
+
const seen = new Set();
|
|
1186
|
+
for (const requirement of requirements) {
|
|
1187
|
+
const key = `${requirement.action}:${requirement.path}:${requirement.message}`;
|
|
1188
|
+
if (seen.has(key))
|
|
1189
|
+
continue;
|
|
1190
|
+
seen.add(key);
|
|
1191
|
+
uniqueRequirements.push(requirement);
|
|
1192
|
+
}
|
|
1193
|
+
const messageLines = ['Required project context is missing:'];
|
|
1194
|
+
for (const requirement of uniqueRequirements) {
|
|
1195
|
+
messageLines.push(`- ${requirement.message}`);
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
jsonrpc: response.jsonrpc,
|
|
1199
|
+
id: response.id ?? null,
|
|
1200
|
+
error: {
|
|
1201
|
+
code: -32003,
|
|
1202
|
+
message: messageLines.join('\n'),
|
|
1203
|
+
data: {
|
|
1204
|
+
missingConfigPaths: uniqueRequirements.map((requirement) => requirement.path)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
973
1209
|
/**
|
|
974
1210
|
* Initialize the LocalRegistryResolver for override resolution
|
|
975
1211
|
*/
|
|
@@ -1057,10 +1293,15 @@ class FraimLocalMCPServer {
|
|
|
1057
1293
|
processResponse(response) {
|
|
1058
1294
|
if (!response.result)
|
|
1059
1295
|
return response;
|
|
1296
|
+
const notices = [];
|
|
1297
|
+
const blockingRequirements = [];
|
|
1060
1298
|
// Recursively substitute templates in all string values
|
|
1061
1299
|
const processValue = (value) => {
|
|
1062
1300
|
if (typeof value === 'string') {
|
|
1063
|
-
|
|
1301
|
+
const substitution = this.ensureEngine().substituteTemplatesWithNotices(value);
|
|
1302
|
+
notices.push(...substitution.notices);
|
|
1303
|
+
blockingRequirements.push(...substitution.blockingRequirements);
|
|
1304
|
+
return substitution.content;
|
|
1064
1305
|
}
|
|
1065
1306
|
else if (Array.isArray(value)) {
|
|
1066
1307
|
return value.map(processValue);
|
|
@@ -1074,14 +1315,25 @@ class FraimLocalMCPServer {
|
|
|
1074
1315
|
}
|
|
1075
1316
|
return value;
|
|
1076
1317
|
};
|
|
1318
|
+
const processedResult = processValue(response.result);
|
|
1319
|
+
if (notices.length > 0 && processedResult && typeof processedResult === 'object') {
|
|
1320
|
+
processedResult.__proxyMissingConfigNotices = notices;
|
|
1321
|
+
}
|
|
1322
|
+
if (blockingRequirements.length > 0 && processedResult && typeof processedResult === 'object') {
|
|
1323
|
+
processedResult.__proxyMissingConfigRequirements = blockingRequirements;
|
|
1324
|
+
}
|
|
1077
1325
|
return {
|
|
1078
1326
|
...response,
|
|
1079
|
-
result:
|
|
1327
|
+
result: processedResult
|
|
1080
1328
|
};
|
|
1081
1329
|
}
|
|
1082
1330
|
applyAgentFallbackForUnresolvedProxy(response) {
|
|
1083
1331
|
if (!response.result)
|
|
1084
1332
|
return response;
|
|
1333
|
+
// Rewrite any unresolved {{proxy.*}} placeholders to {{agent.*}} so the agent can
|
|
1334
|
+
// handle them. proxy.config.* is excluded inside rewriteProxyTokensInText — those
|
|
1335
|
+
// must have been fully resolved by substituteTemplatesWithNotices; any that survive
|
|
1336
|
+
// are bugs and get stripped with a log warning rather than rewritten.
|
|
1085
1337
|
const rewritten = this.rewriteUnresolvedProxyPlaceholders(response.result);
|
|
1086
1338
|
if (rewritten.tokens.length > 0) {
|
|
1087
1339
|
this.logError(`[${FraimLocalMCPServer.FALLBACK_ALERT_MARKER}] Rewrote unresolved proxy placeholders to agent placeholders: ${rewritten.tokens.join(', ')}`);
|
|
@@ -1249,7 +1501,30 @@ class FraimLocalMCPServer {
|
|
|
1249
1501
|
// Resolve templates in the outgoing request so the remote server
|
|
1250
1502
|
// only ever sees finalized values.
|
|
1251
1503
|
const stringifiedRequest = JSON.stringify(request);
|
|
1252
|
-
const
|
|
1504
|
+
const requestSubstitution = this.ensureEngine().substituteTemplatesWithNotices(stringifiedRequest);
|
|
1505
|
+
if (requestSubstitution.blockingRequirements.length > 0) {
|
|
1506
|
+
const uniqueRequirements = [];
|
|
1507
|
+
const seen = new Set();
|
|
1508
|
+
for (const requirement of requestSubstitution.blockingRequirements) {
|
|
1509
|
+
const key = `${requirement.action}:${requirement.path}:${requirement.message}`;
|
|
1510
|
+
if (seen.has(key))
|
|
1511
|
+
continue;
|
|
1512
|
+
seen.add(key);
|
|
1513
|
+
uniqueRequirements.push(requirement);
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
jsonrpc: '2.0',
|
|
1517
|
+
id: request.id,
|
|
1518
|
+
error: {
|
|
1519
|
+
code: -32003,
|
|
1520
|
+
message: ['Required project context is missing:', ...uniqueRequirements.map((requirement) => `- ${requirement.message}`)].join('\n'),
|
|
1521
|
+
data: {
|
|
1522
|
+
missingConfigPaths: uniqueRequirements.map((requirement) => requirement.path)
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
const resolvedRequestStr = requestSubstitution.content;
|
|
1253
1528
|
const finalRequest = JSON.parse(resolvedRequestStr);
|
|
1254
1529
|
const response = await axios_1.default.post(`${this.remoteUrl}/mcp`, finalRequest, {
|
|
1255
1530
|
headers,
|
|
@@ -4,19 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.UsageCollector = void 0;
|
|
7
|
-
const mongodb_1 = require("mongodb");
|
|
8
7
|
const axios_1 = __importDefault(require("axios"));
|
|
9
|
-
// A placeholder ObjectId used when the real API key ID is not yet known.
|
|
10
|
-
// The server will override this with the correct ID from the authenticated API key.
|
|
11
|
-
const PLACEHOLDER_API_KEY_ID = new mongodb_1.ObjectId('000000000000000000000000');
|
|
12
8
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* UsageCollector is responsible for collecting usage events from MCP tools
|
|
10
|
+
* and formatting them for the analytics system.
|
|
15
11
|
*/
|
|
16
12
|
class UsageCollector {
|
|
17
13
|
constructor() {
|
|
18
14
|
this.events = [];
|
|
19
|
-
this.
|
|
15
|
+
this.userId = null;
|
|
20
16
|
}
|
|
21
17
|
static resolveMentoringJobName(args) {
|
|
22
18
|
if (!args || typeof args !== 'object') {
|
|
@@ -35,10 +31,10 @@ class UsageCollector {
|
|
|
35
31
|
return 'unknown';
|
|
36
32
|
}
|
|
37
33
|
/**
|
|
38
|
-
* Set the
|
|
34
|
+
* Set the user ID for this session
|
|
39
35
|
*/
|
|
40
|
-
|
|
41
|
-
this.
|
|
36
|
+
setUserId(userId) {
|
|
37
|
+
this.userId = userId;
|
|
42
38
|
}
|
|
43
39
|
/**
|
|
44
40
|
* Collect MCP tool call event
|
|
@@ -53,19 +49,23 @@ class UsageCollector {
|
|
|
53
49
|
return;
|
|
54
50
|
}
|
|
55
51
|
// Extract useful args for analytics
|
|
56
|
-
const analyticsArgs = this.extractAnalyticsArgs(toolName, args);
|
|
52
|
+
const analyticsArgs = this.extractAnalyticsArgs(toolName, args) || {};
|
|
53
|
+
// Ensure category from path parsing is included in args
|
|
54
|
+
if (parsed.category && !analyticsArgs.category) {
|
|
55
|
+
analyticsArgs.category = parsed.category;
|
|
56
|
+
}
|
|
57
57
|
const event = {
|
|
58
58
|
type: parsed.type,
|
|
59
59
|
name: parsed.name,
|
|
60
|
-
// Use set
|
|
61
|
-
|
|
62
|
-
apiKeyId: this.apiKeyId || PLACEHOLDER_API_KEY_ID,
|
|
60
|
+
// Use set userId if available; the server will override with the authenticated userId.
|
|
61
|
+
userId: this.userId || 'unknown',
|
|
63
62
|
sessionId,
|
|
64
63
|
success,
|
|
65
|
-
|
|
64
|
+
category: parsed.category,
|
|
65
|
+
args: Object.keys(analyticsArgs).length > 0 ? analyticsArgs : undefined
|
|
66
66
|
};
|
|
67
67
|
this.events.push(event);
|
|
68
|
-
const successMsg = `[UsageCollector] ✅ Collected event: ${parsed.type}/${parsed.name} (session: ${sessionId}, queue: ${this.events.length})`;
|
|
68
|
+
const successMsg = `[UsageCollector] ✅ Collected event: ${parsed.type}/${parsed.name} (category: ${parsed.category || 'none'}, session: ${sessionId}, queue: ${this.events.length})`;
|
|
69
69
|
console.error(successMsg);
|
|
70
70
|
// Also log to stderr for better visibility in main logs
|
|
71
71
|
process.stderr.write(successMsg + '\n');
|
|
@@ -73,13 +73,12 @@ class UsageCollector {
|
|
|
73
73
|
/**
|
|
74
74
|
* Collect usage event directly (for backward compatibility with tests)
|
|
75
75
|
*/
|
|
76
|
-
collectEvent(type, name, sessionId, success = true, args) {
|
|
76
|
+
collectEvent(type, name, sessionId, success = true, args, category) {
|
|
77
77
|
const event = {
|
|
78
78
|
type,
|
|
79
79
|
name,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
apiKeyId: this.apiKeyId || PLACEHOLDER_API_KEY_ID,
|
|
80
|
+
category,
|
|
81
|
+
userId: this.userId || 'unknown',
|
|
83
82
|
sessionId,
|
|
84
83
|
success,
|
|
85
84
|
args
|
|
@@ -138,16 +137,18 @@ class UsageCollector {
|
|
|
138
137
|
parseMCPCall(toolName, args) {
|
|
139
138
|
switch (toolName) {
|
|
140
139
|
case 'get_fraim_job':
|
|
141
|
-
|
|
140
|
+
const jobPath = args.job || 'unknown';
|
|
141
|
+
const parsedJob = this.parseComponentName(jobPath);
|
|
142
|
+
return parsedJob || { type: 'job', name: jobPath };
|
|
142
143
|
case 'get_fraim_file':
|
|
143
144
|
if (args.path) {
|
|
144
|
-
return
|
|
145
|
+
return this.parseComponentName(args.path);
|
|
145
146
|
}
|
|
146
147
|
return null;
|
|
147
148
|
case 'seekMentoring':
|
|
148
|
-
return { type: 'mentoring', name: UsageCollector.resolveMentoringJobName(args) };
|
|
149
|
+
return { type: 'mentoring', name: UsageCollector.resolveMentoringJobName(args), category: 'mentoring' };
|
|
149
150
|
case 'list_fraim_jobs':
|
|
150
|
-
return { type: 'job', name: 'list' };
|
|
151
|
+
return { type: 'job', name: 'list', category: 'none' };
|
|
151
152
|
case 'fraim_connect':
|
|
152
153
|
return { type: 'session', name: 'connect' };
|
|
153
154
|
default:
|
|
@@ -226,37 +227,47 @@ class UsageCollector {
|
|
|
226
227
|
/**
|
|
227
228
|
* Parse component name from file path
|
|
228
229
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
230
|
+
parseComponentName(path) {
|
|
231
|
+
if (!path || typeof path !== 'string')
|
|
232
|
+
return null;
|
|
233
|
+
// Clean path: handle backslashes, optional 'registry/' prefix
|
|
234
|
+
let cleanPath = path.replace(/\\/g, '/');
|
|
235
|
+
cleanPath = cleanPath.replace(/^(registry\/)/, '');
|
|
236
|
+
if (cleanPath.startsWith('/'))
|
|
237
|
+
cleanPath = cleanPath.substring(1);
|
|
238
|
+
const parts = cleanPath.split('/');
|
|
239
|
+
const fileName = parts[parts.length - 1];
|
|
240
|
+
// If it doesn't end in .md, it might still be a job name (e.g. from get_fraim_job)
|
|
241
|
+
if (!fileName.endsWith('.md')) {
|
|
242
|
+
return null;
|
|
238
243
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
244
|
+
const name = fileName.replace(/\.md$/, '');
|
|
245
|
+
const typeStr = parts[0];
|
|
246
|
+
// Match skills files
|
|
247
|
+
if (typeStr === 'skills') {
|
|
248
|
+
// Structure: skills/category/name.md or skills/name.md
|
|
249
|
+
// Category is the directory immediately following 'skills'
|
|
250
|
+
const category = parts.length > 2 ? parts[1] : undefined;
|
|
251
|
+
return { type: 'skill', name, category };
|
|
245
252
|
}
|
|
246
|
-
// Match
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
253
|
+
// Match job files
|
|
254
|
+
if (typeStr === 'jobs') {
|
|
255
|
+
// Structure: jobs/ai-employee/category/name.md
|
|
256
|
+
// Or: jobs/category/name.md
|
|
257
|
+
let category;
|
|
258
|
+
if (parts[1] === 'ai-employee' || parts[1] === 'ai-manager' || parts[1] === 'personalized-employee') {
|
|
259
|
+
category = parts[2]; // e.g. jobs/ai-employee/product-building/job.md -> product-building
|
|
260
|
+
}
|
|
261
|
+
else if (parts.length > 2) {
|
|
262
|
+
category = parts[1]; // e.g. jobs/legal/job.md -> legal
|
|
251
263
|
}
|
|
264
|
+
return { type: 'job', name, category };
|
|
252
265
|
}
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const baseName = fileName.replace(/\.md$/, '');
|
|
259
|
-
return { type: 'job', name: baseName };
|
|
266
|
+
// Match rule files (already defined in UsageEventType)
|
|
267
|
+
if (typeStr === 'rules') {
|
|
268
|
+
// Structure: rules/category/name.md or rules/name.md
|
|
269
|
+
const category = parts.length > 2 ? parts[1] : undefined;
|
|
270
|
+
return { type: 'rule', name, category };
|
|
260
271
|
}
|
|
261
272
|
return null;
|
|
262
273
|
}
|