fraim 2.0.100
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 +445 -0
- package/bin/fraim.js +23 -0
- package/dist/src/cli/api/get-provider-client.js +41 -0
- package/dist/src/cli/api/provider-client.js +107 -0
- package/dist/src/cli/commands/add-ide.js +430 -0
- package/dist/src/cli/commands/add-provider.js +233 -0
- package/dist/src/cli/commands/doctor.js +149 -0
- package/dist/src/cli/commands/init-project.js +301 -0
- package/dist/src/cli/commands/list-overridable.js +184 -0
- package/dist/src/cli/commands/list.js +57 -0
- package/dist/src/cli/commands/login.js +84 -0
- package/dist/src/cli/commands/mcp.js +15 -0
- package/dist/src/cli/commands/migrate-project-fraim.js +42 -0
- package/dist/src/cli/commands/override.js +177 -0
- package/dist/src/cli/commands/setup.js +651 -0
- package/dist/src/cli/commands/sync.js +162 -0
- package/dist/src/cli/commands/test-mcp.js +171 -0
- package/dist/src/cli/doctor/check-runner.js +199 -0
- package/dist/src/cli/doctor/checks/global-setup-checks.js +220 -0
- package/dist/src/cli/doctor/checks/ide-config-checks.js +250 -0
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +381 -0
- package/dist/src/cli/doctor/checks/project-setup-checks.js +282 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +157 -0
- package/dist/src/cli/doctor/checks/workflow-checks.js +251 -0
- package/dist/src/cli/doctor/reporters/console-reporter.js +96 -0
- package/dist/src/cli/doctor/reporters/json-reporter.js +11 -0
- package/dist/src/cli/doctor/types.js +6 -0
- package/dist/src/cli/fraim.js +100 -0
- package/dist/src/cli/internal/device-flow-service.js +83 -0
- package/dist/src/cli/mcp/ide-formats.js +243 -0
- package/dist/src/cli/mcp/mcp-server-builder.js +48 -0
- package/dist/src/cli/mcp/mcp-server-registry.js +160 -0
- package/dist/src/cli/mcp/types.js +3 -0
- package/dist/src/cli/providers/local-provider-registry.js +166 -0
- package/dist/src/cli/providers/provider-registry.js +230 -0
- package/dist/src/cli/setup/auto-mcp-setup.js +331 -0
- package/dist/src/cli/setup/codex-local-config.js +37 -0
- package/dist/src/cli/setup/first-run.js +242 -0
- package/dist/src/cli/setup/ide-detector.js +179 -0
- package/dist/src/cli/setup/mcp-config-generator.js +192 -0
- package/dist/src/cli/setup/provider-prompts.js +339 -0
- package/dist/src/cli/utils/agent-adapters.js +126 -0
- package/dist/src/cli/utils/digest-utils.js +47 -0
- package/dist/src/cli/utils/fraim-gitignore.js +40 -0
- package/dist/src/cli/utils/platform-detection.js +258 -0
- package/dist/src/cli/utils/project-bootstrap.js +93 -0
- package/dist/src/cli/utils/remote-sync.js +315 -0
- package/dist/src/cli/utils/script-sync-utils.js +221 -0
- package/dist/src/cli/utils/version-utils.js +32 -0
- package/dist/src/core/ai-mentor.js +230 -0
- package/dist/src/core/config-loader.js +114 -0
- package/dist/src/core/config-writer.js +75 -0
- package/dist/src/core/types.js +23 -0
- package/dist/src/core/utils/git-utils.js +95 -0
- package/dist/src/core/utils/include-resolver.js +92 -0
- package/dist/src/core/utils/inheritance-parser.js +288 -0
- package/dist/src/core/utils/job-parser.js +176 -0
- package/dist/src/core/utils/local-registry-resolver.js +616 -0
- package/dist/src/core/utils/object-utils.js +11 -0
- package/dist/src/core/utils/project-fraim-migration.js +103 -0
- package/dist/src/core/utils/project-fraim-paths.js +38 -0
- package/dist/src/core/utils/provider-utils.js +18 -0
- package/dist/src/core/utils/server-startup.js +34 -0
- package/dist/src/core/utils/stub-generator.js +147 -0
- package/dist/src/core/utils/workflow-parser.js +174 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +229 -0
- package/dist/src/local-mcp-server/stdio-server.js +1698 -0
- package/dist/src/local-mcp-server/usage-collector.js +264 -0
- package/index.js +85 -0
- package/package.json +139 -0
|
@@ -0,0 +1,1698 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* FRAIM Local MCP Server - STDIO Version
|
|
5
|
+
*
|
|
6
|
+
* Proxy that:
|
|
7
|
+
* 1. Accepts MCP requests via stdin/stdout
|
|
8
|
+
* 2. Proxies to remote FRAIM server
|
|
9
|
+
* 3. Performs template substitution:
|
|
10
|
+
* - Proxy config variables: {{proxy.config.path.to.value}}
|
|
11
|
+
* - Platform-specific actions: {{proxy.action.get_issue}}, {{proxy.action.create_pr}}, etc.
|
|
12
|
+
* 4. Automatically detects and injects machine/repo info for fraim_connect
|
|
13
|
+
* 5. Substitutes {{proxy.delivery.*}} templates based on user's workingStyle
|
|
14
|
+
* (PR or Conversation from ~/.fraim/config.json). Delivery phases live
|
|
15
|
+
* server-side in the workflow; the proxy just fills in mode-specific content.
|
|
16
|
+
*/
|
|
17
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
18
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.FraimLocalMCPServer = exports.FraimTemplateEngine = void 0;
|
|
22
|
+
const fs_1 = require("fs");
|
|
23
|
+
const path_1 = require("path");
|
|
24
|
+
const os_1 = require("os");
|
|
25
|
+
const child_process_1 = require("child_process");
|
|
26
|
+
const crypto_1 = require("crypto");
|
|
27
|
+
const axios_1 = __importDefault(require("axios"));
|
|
28
|
+
const provider_utils_1 = require("../core/utils/provider-utils");
|
|
29
|
+
const object_utils_1 = require("../core/utils/object-utils");
|
|
30
|
+
const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
|
|
31
|
+
const ai_mentor_1 = require("../core/ai-mentor");
|
|
32
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
33
|
+
const usage_collector_js_1 = require("./usage-collector.js");
|
|
34
|
+
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
35
|
+
/**
|
|
36
|
+
* Handle template substitution logic separately for better testability
|
|
37
|
+
*/
|
|
38
|
+
class FraimTemplateEngine {
|
|
39
|
+
constructor(opts) {
|
|
40
|
+
this.userEmail = null;
|
|
41
|
+
this.deliveryTemplatesCache = null;
|
|
42
|
+
this.providerTemplatesCache = {};
|
|
43
|
+
this.deliveryTemplatesLoadAttempted = false;
|
|
44
|
+
this.providerTemplatesLoadAttempted = new Set();
|
|
45
|
+
this.config = opts.config;
|
|
46
|
+
this.repoInfo = opts.repoInfo;
|
|
47
|
+
this.workingStyle = opts.workingStyle;
|
|
48
|
+
this.projectRoot = opts.projectRoot;
|
|
49
|
+
this.logFn = opts.logFn || (() => { });
|
|
50
|
+
}
|
|
51
|
+
setDeliveryTemplates(templates) {
|
|
52
|
+
this.deliveryTemplatesCache = templates;
|
|
53
|
+
this.deliveryTemplatesLoadAttempted = true;
|
|
54
|
+
}
|
|
55
|
+
setProviderTemplates(provider, templates) {
|
|
56
|
+
this.providerTemplatesCache[provider] = templates;
|
|
57
|
+
this.providerTemplatesLoadAttempted.add(provider);
|
|
58
|
+
}
|
|
59
|
+
hasDeliveryTemplates() {
|
|
60
|
+
return this.deliveryTemplatesCache !== null;
|
|
61
|
+
}
|
|
62
|
+
hasProviderTemplates(provider) {
|
|
63
|
+
return !!this.providerTemplatesCache[provider];
|
|
64
|
+
}
|
|
65
|
+
setRepoInfo(repoInfo) {
|
|
66
|
+
this.repoInfo = repoInfo;
|
|
67
|
+
}
|
|
68
|
+
setAgentInfo(info) {
|
|
69
|
+
this.agentInfo = info;
|
|
70
|
+
}
|
|
71
|
+
setMachineInfo(info) {
|
|
72
|
+
this.machineInfo = info;
|
|
73
|
+
}
|
|
74
|
+
setConfig(config) {
|
|
75
|
+
this.config = config;
|
|
76
|
+
}
|
|
77
|
+
setUserEmail(email) {
|
|
78
|
+
this.userEmail = email;
|
|
79
|
+
}
|
|
80
|
+
getUserEmail() {
|
|
81
|
+
return this.userEmail;
|
|
82
|
+
}
|
|
83
|
+
substituteTemplates(content) {
|
|
84
|
+
let result = content;
|
|
85
|
+
// Substitute {{proxy.user.email}} with the email captured from fraim_connect
|
|
86
|
+
if (this.userEmail) {
|
|
87
|
+
result = result.replace(/\{\{proxy\.user\.email\}\}/g, this.userEmail);
|
|
88
|
+
}
|
|
89
|
+
// 0. Substitute runtime context tokens: {{agent.*}}, {{machine.*}}, {{repository.*}}
|
|
90
|
+
// These come from the fraim_connect payload captured during handshake.
|
|
91
|
+
const contexts = {
|
|
92
|
+
agent: this.agentInfo,
|
|
93
|
+
machine: this.machineInfo,
|
|
94
|
+
repository: this.repoInfo
|
|
95
|
+
};
|
|
96
|
+
result = result.replace(/\{\{(agent|machine|repository)\.([^}]+)\}\}/g, (match, ns, path) => {
|
|
97
|
+
const source = contexts[ns];
|
|
98
|
+
if (source) {
|
|
99
|
+
const value = (0, object_utils_1.getNestedValue)(source, path.trim());
|
|
100
|
+
if (value !== undefined)
|
|
101
|
+
return String(value);
|
|
102
|
+
}
|
|
103
|
+
return match;
|
|
104
|
+
});
|
|
105
|
+
// First, substitute config variables with fallback support.
|
|
106
|
+
// Fallbacks must work even when local config is unavailable.
|
|
107
|
+
result = result.replace(/\{\{proxy\.config\.([^}|]+)(?:\s*\|\s*"([^"]+)")?\}\}/g, (match, path, fallback) => {
|
|
108
|
+
try {
|
|
109
|
+
if (this.config) {
|
|
110
|
+
const value = (0, object_utils_1.getNestedValue)(this.config, path.trim());
|
|
111
|
+
if (value !== undefined) {
|
|
112
|
+
return typeof value === 'object'
|
|
113
|
+
? JSON.stringify(value)
|
|
114
|
+
: String(value);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return fallback !== undefined ? fallback : match;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return fallback !== undefined ? fallback : match;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// Second, substitute {{proxy.delivery.*}} templates
|
|
124
|
+
const deliveryValues = this.loadDeliveryTemplates();
|
|
125
|
+
if (deliveryValues) {
|
|
126
|
+
result = result.replace(/\{\{proxy\.delivery\.([^}]+)\}\}/g, (match, key) => {
|
|
127
|
+
const value = deliveryValues[`proxy.delivery.${key.trim()}`];
|
|
128
|
+
return value !== undefined ? value : match;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// Third, substitute platform-specific action templates
|
|
132
|
+
result = this.substitutePlatformActions(result);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
loadDeliveryTemplates() {
|
|
136
|
+
if (this.deliveryTemplatesCache)
|
|
137
|
+
return this.deliveryTemplatesCache;
|
|
138
|
+
if (this.deliveryTemplatesLoadAttempted)
|
|
139
|
+
return null;
|
|
140
|
+
this.deliveryTemplatesLoadAttempted = true;
|
|
141
|
+
// Server-authoritative mode: runtime templates are hydrated via cache + remote fetch
|
|
142
|
+
// in FraimLocalMCPServer. Do not read framework/project registry files directly.
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
substitutePlatformActions(content) {
|
|
146
|
+
const codeProvider = this.getCodeProvider();
|
|
147
|
+
const issueProvider = this.getIssueProvider();
|
|
148
|
+
const codeTemplates = this.loadProviderTemplates(codeProvider);
|
|
149
|
+
const issueTemplates = issueProvider ? this.loadProviderTemplates(issueProvider) : null;
|
|
150
|
+
let result = content;
|
|
151
|
+
if (issueTemplates) {
|
|
152
|
+
for (const [action, template] of Object.entries(issueTemplates)) {
|
|
153
|
+
if (!FraimTemplateEngine.ISSUE_ACTIONS.has(action))
|
|
154
|
+
continue;
|
|
155
|
+
const escapedAction = action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
156
|
+
const regex = new RegExp(`\\{\\{${FraimTemplateEngine.PROXY_ACTION_PREFIX}${escapedAction}\\}\\}`, 'g');
|
|
157
|
+
result = result.replace(regex, this.renderActionTemplate(template));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (codeTemplates) {
|
|
161
|
+
for (const [action, template] of Object.entries(codeTemplates)) {
|
|
162
|
+
if (issueProvider && issueProvider !== codeProvider && FraimTemplateEngine.ISSUE_ACTIONS.has(action))
|
|
163
|
+
continue;
|
|
164
|
+
const escapedAction = action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
165
|
+
const regex = new RegExp(`\\{\\{${FraimTemplateEngine.PROXY_ACTION_PREFIX}${escapedAction}\\}\\}`, 'g');
|
|
166
|
+
result = result.replace(regex, this.renderActionTemplate(template));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
getCodeProvider() {
|
|
172
|
+
const repoProvider = this.repoInfo?.provider || this.config?.repository?.provider;
|
|
173
|
+
if (typeof repoProvider === 'string' && repoProvider.trim().length > 0) {
|
|
174
|
+
return repoProvider;
|
|
175
|
+
}
|
|
176
|
+
return (0, provider_utils_1.detectProvider)(this.repoInfo?.url || this.config?.repository?.url);
|
|
177
|
+
}
|
|
178
|
+
getIssueProvider() {
|
|
179
|
+
const fromRepoInfo = this.repoInfo?.issueTracking?.provider;
|
|
180
|
+
if (typeof fromRepoInfo === 'string' && fromRepoInfo.trim().length > 0) {
|
|
181
|
+
return fromRepoInfo;
|
|
182
|
+
}
|
|
183
|
+
const fromConfig = this.config?.issueTracking?.provider;
|
|
184
|
+
if (typeof fromConfig === 'string' && fromConfig.trim().length > 0) {
|
|
185
|
+
return fromConfig;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
loadProviderTemplates(provider) {
|
|
190
|
+
if (this.providerTemplatesCache[provider])
|
|
191
|
+
return this.providerTemplatesCache[provider];
|
|
192
|
+
if (this.providerTemplatesLoadAttempted.has(provider))
|
|
193
|
+
return null;
|
|
194
|
+
this.providerTemplatesLoadAttempted.add(provider);
|
|
195
|
+
// Server-authoritative mode: runtime templates are hydrated via cache + remote fetch
|
|
196
|
+
// in FraimLocalMCPServer. Do not read framework/project registry files directly.
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
renderActionTemplate(template) {
|
|
200
|
+
if (!this.repoInfo && !this.config?.repository && !this.config?.issueTracking) {
|
|
201
|
+
return template;
|
|
202
|
+
}
|
|
203
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
|
|
204
|
+
const trimmedPath = path.trim();
|
|
205
|
+
// Handle proxy.repository.* variables
|
|
206
|
+
if (trimmedPath.startsWith('proxy.repository.')) {
|
|
207
|
+
const repoPath = trimmedPath.substring('proxy.repository.'.length);
|
|
208
|
+
if (this.repoInfo) {
|
|
209
|
+
const value = (0, object_utils_1.getNestedValue)(this.repoInfo, repoPath);
|
|
210
|
+
if (value !== undefined)
|
|
211
|
+
return String(value);
|
|
212
|
+
}
|
|
213
|
+
if (this.config?.repository) {
|
|
214
|
+
const value = (0, object_utils_1.getNestedValue)(this.config.repository, repoPath);
|
|
215
|
+
if (value !== undefined)
|
|
216
|
+
return String(value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Handle proxy.issueTracking.* variables (for split provider mode)
|
|
220
|
+
if (trimmedPath.startsWith('proxy.issueTracking.')) {
|
|
221
|
+
const issueTrackingPath = trimmedPath.substring('proxy.issueTracking.'.length);
|
|
222
|
+
if (this.repoInfo?.issueTracking) {
|
|
223
|
+
const value = (0, object_utils_1.getNestedValue)(this.repoInfo.issueTracking, issueTrackingPath);
|
|
224
|
+
if (value !== undefined)
|
|
225
|
+
return String(value);
|
|
226
|
+
}
|
|
227
|
+
if (this.config?.issueTracking) {
|
|
228
|
+
const value = (0, object_utils_1.getNestedValue)(this.config.issueTracking, issueTrackingPath);
|
|
229
|
+
if (value !== undefined)
|
|
230
|
+
return String(value);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return match;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.FraimTemplateEngine = FraimTemplateEngine;
|
|
238
|
+
FraimTemplateEngine.PROXY_ACTION_PREFIX = 'proxy.action.';
|
|
239
|
+
FraimTemplateEngine.ISSUE_ACTIONS = new Set([
|
|
240
|
+
'get_issue',
|
|
241
|
+
'update_issue_status',
|
|
242
|
+
'add_issue_comment',
|
|
243
|
+
'create_issue',
|
|
244
|
+
'assign_issue',
|
|
245
|
+
'search_issues',
|
|
246
|
+
'close_issue',
|
|
247
|
+
'list_issues'
|
|
248
|
+
]);
|
|
249
|
+
class FraimLocalMCPServer {
|
|
250
|
+
constructor(writer) {
|
|
251
|
+
this.config = null;
|
|
252
|
+
this.clientSupportsRoots = false;
|
|
253
|
+
this.workspaceRoot = null;
|
|
254
|
+
this.pendingRootsRequest = false;
|
|
255
|
+
this.agentInfo = null;
|
|
256
|
+
this.machineInfo = null;
|
|
257
|
+
this.repoInfo = null;
|
|
258
|
+
this.engine = null;
|
|
259
|
+
this.writer = writer || process.stdout.write.bind(process.stdout);
|
|
260
|
+
this.remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
261
|
+
this.apiKey = this.loadApiKey();
|
|
262
|
+
this.localVersion = this.detectLocalVersion();
|
|
263
|
+
if (!this.apiKey) {
|
|
264
|
+
this.logError('❌ FRAIM API key is required');
|
|
265
|
+
this.logError(' Set FRAIM_API_KEY environment variable or add apiKey to ~/.fraim/config.json');
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
this.log('🚀 FRAIM Local MCP Server starting... [DEBUG-PROXY-V3]');
|
|
269
|
+
this.log(`📡 Remote server: ${this.remoteUrl}`);
|
|
270
|
+
this.log(`🔑 API key: ${this.apiKey.substring(0, 10)}...`);
|
|
271
|
+
this.log(`Local MCP version: ${this.localVersion}`);
|
|
272
|
+
this.log(`🔍 DEBUG BUILD: Machine detection v2 active`);
|
|
273
|
+
// Initialize usage collector
|
|
274
|
+
this.usageCollector = new usage_collector_js_1.UsageCollector();
|
|
275
|
+
this.log('📊 Usage analytics collector initialized');
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Load API key from environment variable or user config file
|
|
279
|
+
* Priority: FRAIM_API_KEY env var > ~/.fraim/config.json
|
|
280
|
+
*/
|
|
281
|
+
loadApiKey() {
|
|
282
|
+
// First try environment variable (for IDE MCP configs)
|
|
283
|
+
if (process.env.FRAIM_API_KEY) {
|
|
284
|
+
return process.env.FRAIM_API_KEY;
|
|
285
|
+
}
|
|
286
|
+
// Fallback to user config file
|
|
287
|
+
try {
|
|
288
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
289
|
+
if (!homeDir)
|
|
290
|
+
return '';
|
|
291
|
+
const configPath = (0, path_1.join)(homeDir, '.fraim', 'config.json');
|
|
292
|
+
if ((0, fs_1.existsSync)(configPath)) {
|
|
293
|
+
const config = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
|
|
294
|
+
if (config.apiKey) {
|
|
295
|
+
return config.apiKey;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// Ignore errors, will fail with clear message below
|
|
301
|
+
}
|
|
302
|
+
return '';
|
|
303
|
+
}
|
|
304
|
+
log(message) {
|
|
305
|
+
// Log to stderr (stdout is reserved for MCP protocol)
|
|
306
|
+
const key = this.apiKey || 'MISSING_API_KEY';
|
|
307
|
+
console.error(`[FRAIM key:${key}] ${message}`);
|
|
308
|
+
// Also log to file for debugging
|
|
309
|
+
try {
|
|
310
|
+
const fs = require('fs');
|
|
311
|
+
const logFile = require('path').join(require('os').tmpdir(), 'fraim-mcp-proxy.log');
|
|
312
|
+
fs.appendFileSync(logFile, `${new Date().toISOString()} [${key}] ${message}\n`);
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
// Ignore file logging errors
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
logError(message) {
|
|
319
|
+
const key = this.apiKey || 'MISSING_API_KEY';
|
|
320
|
+
console.error(`[FRAIM ERROR key:${key}] ${message}`);
|
|
321
|
+
}
|
|
322
|
+
detectLocalVersion() {
|
|
323
|
+
const candidates = [
|
|
324
|
+
(0, path_1.join)(__dirname, '..', '..', '..', 'package.json'),
|
|
325
|
+
(0, path_1.join)(__dirname, '..', '..', 'package.json')
|
|
326
|
+
];
|
|
327
|
+
for (const pkgPath of candidates) {
|
|
328
|
+
try {
|
|
329
|
+
if (!(0, fs_1.existsSync)(pkgPath))
|
|
330
|
+
continue;
|
|
331
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf8'));
|
|
332
|
+
if (typeof pkg.version === 'string' && pkg.version.trim().length > 0) {
|
|
333
|
+
return pkg.version;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Ignore and try the next candidate
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return 'unknown';
|
|
341
|
+
}
|
|
342
|
+
findProjectRoot() {
|
|
343
|
+
// If we already have workspace root from MCP roots, use it
|
|
344
|
+
if (this.workspaceRoot) {
|
|
345
|
+
this.log(`✅ Using workspace root from MCP roots: ${this.workspaceRoot}`);
|
|
346
|
+
return this.workspaceRoot;
|
|
347
|
+
}
|
|
348
|
+
// Priority 1: Check for IDE-provided workspace environment variables
|
|
349
|
+
const workspaceHints = [
|
|
350
|
+
process.env.WORKSPACE_FOLDER_PATHS?.split(':')[0], // Cursor provides this (colon-separated for multi-root)
|
|
351
|
+
process.env.WORKSPACE_FOLDER, // VSCode and others
|
|
352
|
+
process.env.PROJECT_ROOT,
|
|
353
|
+
process.env.VSCODE_CWD,
|
|
354
|
+
process.env.INIT_CWD,
|
|
355
|
+
];
|
|
356
|
+
for (const hint of workspaceHints) {
|
|
357
|
+
if (hint && (0, project_fraim_paths_1.workspaceFraimExists)(hint)) {
|
|
358
|
+
this.log(`Found ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)()} via workspace env var: ${hint}`);
|
|
359
|
+
return hint;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Priority 2: Search upwards from cwd, but skip home directory
|
|
363
|
+
let currentDir = process.cwd();
|
|
364
|
+
const root = (0, path_1.parse)(currentDir).root;
|
|
365
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
366
|
+
this.log(`🔍 Starting search from: ${currentDir}`);
|
|
367
|
+
if (homeDir) {
|
|
368
|
+
this.log(`🏠 Home directory (will skip): ${homeDir}`);
|
|
369
|
+
}
|
|
370
|
+
while (currentDir !== root) {
|
|
371
|
+
const fraimDir = (0, project_fraim_paths_1.getWorkspaceConfigPath)(currentDir).replace(/[\\/]config\.json$/, '');
|
|
372
|
+
this.log(` Checking: ${fraimDir}`);
|
|
373
|
+
if ((0, fs_1.existsSync)(fraimDir)) {
|
|
374
|
+
// Skip the home directory FRAIM dir and continue searching for a project-specific one
|
|
375
|
+
if (homeDir && currentDir === homeDir) {
|
|
376
|
+
this.log(`Skipping home directory ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)()}, continuing search...`);
|
|
377
|
+
currentDir = (0, path_1.dirname)(currentDir);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
this.log(`Found workspace FRAIM dir at: ${currentDir}`);
|
|
381
|
+
return currentDir;
|
|
382
|
+
}
|
|
383
|
+
currentDir = (0, path_1.dirname)(currentDir);
|
|
384
|
+
}
|
|
385
|
+
// Priority 3: Fall back to the home directory .fraim if nothing else found
|
|
386
|
+
if (homeDir && (0, fs_1.existsSync)((0, path_1.join)(homeDir, '.fraim'))) {
|
|
387
|
+
this.log(`⚠️ Using home directory .fraim as fallback: ${homeDir}`);
|
|
388
|
+
return homeDir;
|
|
389
|
+
}
|
|
390
|
+
this.log('No workspace FRAIM directory found');
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
loadConfig() {
|
|
394
|
+
try {
|
|
395
|
+
this.log(`📍 Process started from: ${process.cwd()}`);
|
|
396
|
+
// Try to find the project root by searching for the workspace FRAIM directory
|
|
397
|
+
const projectDir = this.findProjectRoot() || process.cwd();
|
|
398
|
+
const configPath = (0, project_fraim_paths_1.getWorkspaceConfigPath)(projectDir);
|
|
399
|
+
this.log(`🔍 Looking for config at: ${configPath}`);
|
|
400
|
+
if ((0, fs_1.existsSync)(configPath)) {
|
|
401
|
+
const configContent = (0, fs_1.readFileSync)(configPath, 'utf8');
|
|
402
|
+
this.config = JSON.parse(configContent);
|
|
403
|
+
this.log(`Loaded local ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('config.json')}`);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
this.config = null;
|
|
407
|
+
this.log(`No ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('config.json')} found - template substitution disabled`);
|
|
408
|
+
}
|
|
409
|
+
if (this.engine) {
|
|
410
|
+
this.engine.setConfig(this.config);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
this.logError(`Failed to load config: ${error}`);
|
|
415
|
+
this.config = null;
|
|
416
|
+
if (this.engine) {
|
|
417
|
+
this.engine.setConfig(this.config);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Automatically detect machine information
|
|
423
|
+
*/
|
|
424
|
+
detectMachineInfo() {
|
|
425
|
+
if (this.machineInfo) {
|
|
426
|
+
this.log(`🔄 Returning cached machine info: ${JSON.stringify(this.machineInfo)}`);
|
|
427
|
+
return this.machineInfo;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
this.machineInfo = {
|
|
431
|
+
hostname: (0, os_1.hostname)(),
|
|
432
|
+
platform: (0, os_1.platform)(),
|
|
433
|
+
memory: (0, os_1.totalmem)(),
|
|
434
|
+
cpus: (0, os_1.cpus)().length
|
|
435
|
+
};
|
|
436
|
+
this.log(`✅ Detected machine info: ${JSON.stringify(this.machineInfo)}`);
|
|
437
|
+
return this.machineInfo;
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
this.logError(`Failed to detect machine info: ${error}`);
|
|
441
|
+
return {
|
|
442
|
+
hostname: 'unknown',
|
|
443
|
+
platform: 'unknown'
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Automatically detect repository information from git
|
|
449
|
+
*/
|
|
450
|
+
detectRepoInfo() {
|
|
451
|
+
if (this.repoInfo) {
|
|
452
|
+
return this.repoInfo;
|
|
453
|
+
}
|
|
454
|
+
// Ensure config is loaded before trying to detect repo info
|
|
455
|
+
if (!this.config) {
|
|
456
|
+
this.loadConfig();
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const projectDir = this.findProjectRoot() || process.cwd();
|
|
460
|
+
// Try to get git remote URL
|
|
461
|
+
let repoUrl = '';
|
|
462
|
+
try {
|
|
463
|
+
repoUrl = (0, child_process_1.execSync)('git remote get-url origin', {
|
|
464
|
+
cwd: projectDir,
|
|
465
|
+
encoding: 'utf8',
|
|
466
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
467
|
+
}).trim();
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
// Fall back to config if git fails
|
|
471
|
+
repoUrl = this.config?.repository?.url || '';
|
|
472
|
+
}
|
|
473
|
+
if (!repoUrl) {
|
|
474
|
+
this.log('No git repository found and no config available');
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
// Parse repository identity from URL
|
|
478
|
+
let name = '';
|
|
479
|
+
let owner = '';
|
|
480
|
+
let organization = '';
|
|
481
|
+
let project = '';
|
|
482
|
+
let namespace = '';
|
|
483
|
+
let projectPath = '';
|
|
484
|
+
const githubMatch = repoUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\.]+)/i);
|
|
485
|
+
const adoMatch = repoUrl.match(/dev\.azure\.com\/([^\/]+)\/([^\/]+)\/_git\/([^\/]+)/i);
|
|
486
|
+
const gitlabMatch = repoUrl.match(/gitlab[^\/:]*[\/:]([^?\s#]+?)(?:\.git)?$/i);
|
|
487
|
+
if (githubMatch) {
|
|
488
|
+
owner = githubMatch[1];
|
|
489
|
+
name = githubMatch[2];
|
|
490
|
+
}
|
|
491
|
+
else if (adoMatch) {
|
|
492
|
+
organization = adoMatch[1];
|
|
493
|
+
project = adoMatch[2];
|
|
494
|
+
name = adoMatch[3];
|
|
495
|
+
}
|
|
496
|
+
else if (gitlabMatch) {
|
|
497
|
+
projectPath = gitlabMatch[1].replace(/^\/+/, '');
|
|
498
|
+
const segments = projectPath.split('/').filter(Boolean);
|
|
499
|
+
name = segments[segments.length - 1] || '';
|
|
500
|
+
namespace = segments.slice(0, -1).join('/');
|
|
501
|
+
}
|
|
502
|
+
else if (this.config?.repository) {
|
|
503
|
+
// Fall back to config if URL parsing fails
|
|
504
|
+
owner = this.config.repository.owner || '';
|
|
505
|
+
name = this.config.repository.name || '';
|
|
506
|
+
organization = this.config.repository.organization || '';
|
|
507
|
+
project = this.config.repository.project || '';
|
|
508
|
+
namespace = this.config.repository.namespace || '';
|
|
509
|
+
projectPath = this.config.repository.projectPath || (namespace && name ? `${namespace}/${name}` : '');
|
|
510
|
+
}
|
|
511
|
+
// Get current branch
|
|
512
|
+
let branch = '';
|
|
513
|
+
try {
|
|
514
|
+
branch = (0, child_process_1.execSync)('git branch --show-current', {
|
|
515
|
+
cwd: projectDir,
|
|
516
|
+
encoding: 'utf8',
|
|
517
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
518
|
+
}).trim();
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
// Fall back to config default branch if available
|
|
522
|
+
if (this.config?.repository?.defaultBranch) {
|
|
523
|
+
branch = this.config.repository.defaultBranch;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const repoInfo = {
|
|
527
|
+
url: repoUrl,
|
|
528
|
+
name: name || 'unknown',
|
|
529
|
+
...(organization && { organization }),
|
|
530
|
+
...(project && { project }),
|
|
531
|
+
...(namespace && { namespace }),
|
|
532
|
+
...(projectPath && { projectPath }),
|
|
533
|
+
...(branch && { branch })
|
|
534
|
+
};
|
|
535
|
+
if (owner) {
|
|
536
|
+
repoInfo.owner = owner;
|
|
537
|
+
}
|
|
538
|
+
this.repoInfo = repoInfo;
|
|
539
|
+
const repoLabel = this.repoInfo.owner
|
|
540
|
+
? `${this.repoInfo.owner}/${this.repoInfo.name}`
|
|
541
|
+
: this.repoInfo.projectPath || this.repoInfo.name;
|
|
542
|
+
this.log(`Detected repo info: ${repoLabel}`);
|
|
543
|
+
return this.repoInfo;
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
this.logError(`Failed to detect repo info: ${error}`);
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Get the user's working style preference from ~/.fraim/config.json
|
|
552
|
+
*/
|
|
553
|
+
getWorkingStyle() {
|
|
554
|
+
// Workspace config takes precedence over global config
|
|
555
|
+
if (this.config?.workingStyle) {
|
|
556
|
+
return this.config.workingStyle;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
560
|
+
const configPath = (0, path_1.join)(homeDir, '.fraim', 'config.json');
|
|
561
|
+
if ((0, fs_1.existsSync)(configPath)) {
|
|
562
|
+
const config = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
|
|
563
|
+
return config.workingStyle || 'PR';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Ignore errors, default to PR
|
|
568
|
+
}
|
|
569
|
+
return 'PR';
|
|
570
|
+
}
|
|
571
|
+
getHomeDir() {
|
|
572
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
573
|
+
return homeDir || null;
|
|
574
|
+
}
|
|
575
|
+
getProviderCachePath(filename) {
|
|
576
|
+
const homeDir = this.getHomeDir();
|
|
577
|
+
if (!homeDir)
|
|
578
|
+
return null;
|
|
579
|
+
return (0, path_1.join)(homeDir, '.fraim', 'cache', 'registry', 'providers', filename);
|
|
580
|
+
}
|
|
581
|
+
readCachedTemplateFile(filename) {
|
|
582
|
+
try {
|
|
583
|
+
const cachePath = this.getProviderCachePath(filename);
|
|
584
|
+
if (!cachePath || !(0, fs_1.existsSync)(cachePath))
|
|
585
|
+
return null;
|
|
586
|
+
const content = (0, fs_1.readFileSync)(cachePath, 'utf8');
|
|
587
|
+
const parsed = JSON.parse(content);
|
|
588
|
+
if (parsed && typeof parsed === 'object') {
|
|
589
|
+
this.log(`✅ Loaded template cache: ${cachePath}`);
|
|
590
|
+
return parsed;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
this.log(`⚠️ Failed to read template cache ${filename}: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
writeCachedTemplateFile(filename, templates) {
|
|
599
|
+
try {
|
|
600
|
+
const cachePath = this.getProviderCachePath(filename);
|
|
601
|
+
if (!cachePath)
|
|
602
|
+
return;
|
|
603
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(cachePath), { recursive: true });
|
|
604
|
+
(0, fs_1.writeFileSync)(cachePath, JSON.stringify(templates), 'utf8');
|
|
605
|
+
this.log(`✅ Cached template file: ${cachePath}`);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
this.log(`⚠️ Failed to cache template ${filename}: ${error.message}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
ensureEngine() {
|
|
612
|
+
if (!this.engine) {
|
|
613
|
+
const repoInfo = this.detectRepoInfo();
|
|
614
|
+
this.engine = new FraimTemplateEngine({
|
|
615
|
+
config: this.config,
|
|
616
|
+
repoInfo,
|
|
617
|
+
workingStyle: this.getWorkingStyle(),
|
|
618
|
+
projectRoot: this.findProjectRoot(),
|
|
619
|
+
logFn: (msg) => this.log(msg)
|
|
620
|
+
});
|
|
621
|
+
// Apply stored runtime context if available.
|
|
622
|
+
if (this.agentInfo)
|
|
623
|
+
this.engine.setAgentInfo(this.agentInfo);
|
|
624
|
+
if (this.machineInfo)
|
|
625
|
+
this.engine.setMachineInfo(this.machineInfo);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
this.engine.setConfig(this.config);
|
|
629
|
+
if (this.repoInfo) {
|
|
630
|
+
this.engine.setRepoInfo(this.repoInfo);
|
|
631
|
+
}
|
|
632
|
+
if (this.agentInfo)
|
|
633
|
+
this.engine.setAgentInfo(this.agentInfo);
|
|
634
|
+
if (this.machineInfo)
|
|
635
|
+
this.engine.setMachineInfo(this.machineInfo);
|
|
636
|
+
}
|
|
637
|
+
return this.engine;
|
|
638
|
+
}
|
|
639
|
+
collectStrings(value, output) {
|
|
640
|
+
if (typeof value === 'string') {
|
|
641
|
+
output.push(value);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (Array.isArray(value)) {
|
|
645
|
+
value.forEach(item => this.collectStrings(item, output));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (value && typeof value === 'object') {
|
|
649
|
+
Object.values(value).forEach(item => this.collectStrings(item, output));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
responseContainsDeliveryPlaceholders(response) {
|
|
653
|
+
if (!response.result)
|
|
654
|
+
return false;
|
|
655
|
+
const strings = [];
|
|
656
|
+
this.collectStrings(response.result, strings);
|
|
657
|
+
return strings.some(s => /\{\{proxy\.delivery\.[^}]+\}\}/.test(s));
|
|
658
|
+
}
|
|
659
|
+
responseHasPotentialProviderPlaceholders(response) {
|
|
660
|
+
if (!response.result)
|
|
661
|
+
return false;
|
|
662
|
+
const strings = [];
|
|
663
|
+
this.collectStrings(response.result, strings);
|
|
664
|
+
return strings.some(s => /\{\{proxy\.action\.[^}]+\}\}/.test(s));
|
|
665
|
+
}
|
|
666
|
+
async fetchRegistryFileFromServer(path, requestSessionId) {
|
|
667
|
+
const request = {
|
|
668
|
+
jsonrpc: '2.0',
|
|
669
|
+
id: (0, crypto_1.randomUUID)(),
|
|
670
|
+
method: 'tools/call',
|
|
671
|
+
params: {
|
|
672
|
+
name: 'get_fraim_file',
|
|
673
|
+
arguments: {
|
|
674
|
+
path,
|
|
675
|
+
raw: true
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
this.applyRequestSessionId(request, requestSessionId);
|
|
680
|
+
const response = await this._doProxyToRemote(request);
|
|
681
|
+
if (response.error) {
|
|
682
|
+
this.log(`⚠️ Failed to fetch registry file ${path}: ${response.error.message}`);
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
const textBlock = response.result?.content?.find((c) => c.type === 'text');
|
|
686
|
+
if (!textBlock?.text) {
|
|
687
|
+
this.log(`⚠️ No text content in registry response for ${path}`);
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
return textBlock.text;
|
|
691
|
+
}
|
|
692
|
+
parseTemplateJson(content, context) {
|
|
693
|
+
const candidates = [];
|
|
694
|
+
const trimmed = content.trim();
|
|
695
|
+
candidates.push(trimmed);
|
|
696
|
+
// get_fraim_file responses include a markdown header, then a separator line, then file content.
|
|
697
|
+
const separatorIndex = trimmed.indexOf('\n---\n');
|
|
698
|
+
if (separatorIndex >= 0) {
|
|
699
|
+
candidates.push(trimmed.slice(separatorIndex + '\n---\n'.length).trim());
|
|
700
|
+
}
|
|
701
|
+
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
702
|
+
if (codeFenceMatch?.[1]) {
|
|
703
|
+
candidates.push(codeFenceMatch[1].trim());
|
|
704
|
+
}
|
|
705
|
+
const firstBrace = trimmed.indexOf('{');
|
|
706
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
707
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
708
|
+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
709
|
+
}
|
|
710
|
+
for (const candidate of candidates) {
|
|
711
|
+
if (!candidate)
|
|
712
|
+
continue;
|
|
713
|
+
try {
|
|
714
|
+
const parsed = JSON.parse(candidate);
|
|
715
|
+
if (parsed && typeof parsed === 'object') {
|
|
716
|
+
return parsed;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// Try the next extraction candidate.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
this.log(`⚠️ Failed to parse ${context}: no valid JSON object found in response`);
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
async ensureDeliveryTemplatesAvailable(response, requestSessionId) {
|
|
727
|
+
if (!this.responseContainsDeliveryPlaceholders(response))
|
|
728
|
+
return;
|
|
729
|
+
const engine = this.ensureEngine();
|
|
730
|
+
if (engine.hasDeliveryTemplates())
|
|
731
|
+
return;
|
|
732
|
+
if (engine.loadDeliveryTemplates())
|
|
733
|
+
return;
|
|
734
|
+
const filename = this.getWorkingStyle() === 'Conversation'
|
|
735
|
+
? 'delivery-conversation.json'
|
|
736
|
+
: 'delivery-pr.json';
|
|
737
|
+
const cached = this.readCachedTemplateFile(filename);
|
|
738
|
+
if (cached) {
|
|
739
|
+
engine.setDeliveryTemplates(cached);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const remoteContent = await this.fetchRegistryFileFromServer(`providers/${filename}`, requestSessionId);
|
|
743
|
+
if (!remoteContent)
|
|
744
|
+
return;
|
|
745
|
+
const parsed = this.parseTemplateJson(remoteContent, `providers/${filename}`);
|
|
746
|
+
if (!parsed)
|
|
747
|
+
return;
|
|
748
|
+
engine.setDeliveryTemplates(parsed);
|
|
749
|
+
this.writeCachedTemplateFile(filename, parsed);
|
|
750
|
+
}
|
|
751
|
+
async ensureProviderTemplatesAvailable(response, requestSessionId) {
|
|
752
|
+
const hasDirectProviderPlaceholders = this.responseHasPotentialProviderPlaceholders(response);
|
|
753
|
+
const hasDeliveryPlaceholders = this.responseContainsDeliveryPlaceholders(response);
|
|
754
|
+
if (!hasDirectProviderPlaceholders && !hasDeliveryPlaceholders)
|
|
755
|
+
return;
|
|
756
|
+
const engine = this.ensureEngine();
|
|
757
|
+
const codeProvider = engine.getCodeProvider();
|
|
758
|
+
const issueProvider = engine.getIssueProvider();
|
|
759
|
+
const providers = Array.from(new Set([codeProvider, issueProvider].filter((provider) => typeof provider === 'string' && provider.length > 0)));
|
|
760
|
+
for (const provider of providers) {
|
|
761
|
+
if (engine.hasProviderTemplates(provider))
|
|
762
|
+
continue;
|
|
763
|
+
if (engine.loadProviderTemplates(provider))
|
|
764
|
+
continue;
|
|
765
|
+
const filename = `${provider}.json`;
|
|
766
|
+
const cached = this.readCachedTemplateFile(filename);
|
|
767
|
+
if (cached) {
|
|
768
|
+
engine.setProviderTemplates(provider, cached);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const remoteContent = await this.fetchRegistryFileFromServer(`providers/${filename}`, requestSessionId);
|
|
772
|
+
if (remoteContent) {
|
|
773
|
+
const parsed = this.parseTemplateJson(remoteContent, `providers/${filename}`);
|
|
774
|
+
if (parsed) {
|
|
775
|
+
engine.setProviderTemplates(provider, parsed);
|
|
776
|
+
this.writeCachedTemplateFile(filename, parsed);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async hydrateTemplateCachesForResponse(response, requestSessionId) {
|
|
782
|
+
if (!response.result)
|
|
783
|
+
return;
|
|
784
|
+
await this.ensureDeliveryTemplatesAvailable(response, requestSessionId);
|
|
785
|
+
await this.ensureProviderTemplatesAvailable(response, requestSessionId);
|
|
786
|
+
}
|
|
787
|
+
async processResponseWithHydration(response, requestSessionId) {
|
|
788
|
+
await this.hydrateTemplateCachesForResponse(response, requestSessionId);
|
|
789
|
+
let processedResponse = this.processResponse(response);
|
|
790
|
+
// Delivery substitution can introduce provider action placeholders (e.g. {{proxy.action.update_issue_status}})
|
|
791
|
+
// that were not visible pre-substitution. Run one targeted re-hydration pass if needed.
|
|
792
|
+
const engine = this.ensureEngine();
|
|
793
|
+
const codeProvider = engine.getCodeProvider();
|
|
794
|
+
const issueProvider = engine.getIssueProvider();
|
|
795
|
+
const providers = Array.from(new Set([codeProvider, issueProvider].filter((provider) => typeof provider === 'string' && provider.length > 0)));
|
|
796
|
+
const hasMissingProviderTemplate = providers.some((provider) => !engine.hasProviderTemplates(provider));
|
|
797
|
+
if (this.responseHasPotentialProviderPlaceholders(processedResponse) && hasMissingProviderTemplate) {
|
|
798
|
+
await this.ensureProviderTemplatesAvailable(processedResponse, requestSessionId);
|
|
799
|
+
processedResponse = this.processResponse(processedResponse);
|
|
800
|
+
}
|
|
801
|
+
return this.applyAgentFallbackForUnresolvedProxy(processedResponse);
|
|
802
|
+
}
|
|
803
|
+
async resolveIncludesInResponse(response, requestSessionId, requestId) {
|
|
804
|
+
if (!response.result?.content || !Array.isArray(response.result.content)) {
|
|
805
|
+
return response;
|
|
806
|
+
}
|
|
807
|
+
const resolver = this.getRegistryResolver(requestSessionId);
|
|
808
|
+
const transformedContent = [];
|
|
809
|
+
for (const block of response.result.content) {
|
|
810
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') {
|
|
811
|
+
transformedContent.push(block);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
// resolver.resolveIncludes handles recursion internally via the remoteContentResolver
|
|
815
|
+
// configured in getRegistryResolver, which correctly uses _doProxyToRemote.
|
|
816
|
+
const resolvedText = await resolver.resolveIncludes(block.text);
|
|
817
|
+
transformedContent.push({
|
|
818
|
+
...block,
|
|
819
|
+
text: resolvedText
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
...response,
|
|
824
|
+
result: {
|
|
825
|
+
...response.result,
|
|
826
|
+
content: transformedContent
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
async finalizeToolResponse(request, response, requestSessionId, requestId) {
|
|
831
|
+
const toolName = request.params?.name;
|
|
832
|
+
const args = request.params?.arguments || {};
|
|
833
|
+
if (request.method !== 'tools/call' || typeof toolName !== 'string') {
|
|
834
|
+
return this.processResponseWithHydration(response, requestSessionId);
|
|
835
|
+
}
|
|
836
|
+
const projectRoot = this.findProjectRoot();
|
|
837
|
+
const resolver = projectRoot ? this.getRegistryResolver(requestSessionId) : null;
|
|
838
|
+
let finalizedResponse = response;
|
|
839
|
+
// 1. Resolve top-level override if this tool maps to a specific file.
|
|
840
|
+
if (resolver) {
|
|
841
|
+
let registryPath = null;
|
|
842
|
+
if (toolName === 'get_fraim_file')
|
|
843
|
+
registryPath = args.path;
|
|
844
|
+
else if (toolName === 'get_fraim_skill')
|
|
845
|
+
registryPath = await resolver.findRegistryPath('skills', args.skill);
|
|
846
|
+
else if (toolName === 'get_fraim_job')
|
|
847
|
+
registryPath = await resolver.findRegistryPath('jobs', args.job);
|
|
848
|
+
if (registryPath) {
|
|
849
|
+
const localOverride = resolver.hasLocalOverride(registryPath);
|
|
850
|
+
const remoteFailed = Boolean(response.error);
|
|
851
|
+
if (response.error) {
|
|
852
|
+
this.log(`[req:${requestId}] Remote error for ${registryPath}: code=${response.error.code}`);
|
|
853
|
+
}
|
|
854
|
+
if (localOverride || remoteFailed) {
|
|
855
|
+
try {
|
|
856
|
+
// personalized-employee/ takes priority; when remote fails, resolver falls back to synced/cached content
|
|
857
|
+
const resolved = await resolver.resolveFile(registryPath).catch(() => null);
|
|
858
|
+
if (resolved?.content) {
|
|
859
|
+
const substitutedContent = this.substituteTemplates(resolved.content);
|
|
860
|
+
const newResponse = {
|
|
861
|
+
...response,
|
|
862
|
+
result: {
|
|
863
|
+
...response.result,
|
|
864
|
+
content: [{ type: 'text', text: substitutedContent }]
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
delete newResponse.error;
|
|
868
|
+
finalizedResponse = newResponse;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
this.log(`[req:${requestId}] Top-level local resolution failed for ${registryPath}: ${error.message}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// 2. Resolve includes within the content (for all registry tools)
|
|
878
|
+
finalizedResponse = await this.resolveIncludesInResponse(finalizedResponse, requestSessionId, requestId);
|
|
879
|
+
// 3. After fraim_connect succeeds, capture user email and inject learning context.
|
|
880
|
+
if (toolName === 'fraim_connect' && !finalizedResponse.error) {
|
|
881
|
+
const text = finalizedResponse.result?.content?.[0]?.text;
|
|
882
|
+
if (typeof text === 'string') {
|
|
883
|
+
const emailMatch = text.match(/Your identity for this session: \*\*([^*]+)\*\*/);
|
|
884
|
+
if (emailMatch) {
|
|
885
|
+
const userEmail = emailMatch[1].trim();
|
|
886
|
+
this.ensureEngine().setUserEmail(userEmail);
|
|
887
|
+
this.log(`[req:${requestId}] Captured user email for template substitution: ${userEmail}`);
|
|
888
|
+
// Inject learning context from the local workspace (RFC 177: files live on disk, not server).
|
|
889
|
+
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
890
|
+
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, false);
|
|
891
|
+
if (learningSection) {
|
|
892
|
+
finalizedResponse.result.content[0].text = text + learningSection;
|
|
893
|
+
this.log(`[req:${requestId}] Injected learning context for ${userEmail} from ${workspaceRoot}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// 4. After get_fraim_job succeeds, inject learning context with job-focus frame.
|
|
899
|
+
if (toolName === 'get_fraim_job' && !finalizedResponse.error) {
|
|
900
|
+
const text = finalizedResponse.result?.content?.[0]?.text;
|
|
901
|
+
const userEmail = this.ensureEngine().getUserEmail();
|
|
902
|
+
if (typeof text === 'string' && userEmail) {
|
|
903
|
+
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
904
|
+
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
|
|
905
|
+
if (learningSection) {
|
|
906
|
+
finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection;
|
|
907
|
+
this.log(`[req:${requestId}] Injected job-focus learning context for ${userEmail}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return this.processResponseWithHydration(finalizedResponse, requestSessionId);
|
|
912
|
+
}
|
|
913
|
+
async finalizeLocalToolTextResponse(request, requestSessionId, requestId, text) {
|
|
914
|
+
const response = {
|
|
915
|
+
jsonrpc: '2.0',
|
|
916
|
+
id: request.id,
|
|
917
|
+
result: {
|
|
918
|
+
content: [{ type: 'text', text }]
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
const withResolvedIncludes = await this.resolveIncludesInResponse(response, requestSessionId, requestId);
|
|
922
|
+
return this.processResponseWithHydration(withResolvedIncludes, requestSessionId);
|
|
923
|
+
}
|
|
924
|
+
rewriteProxyTokensInText(text) {
|
|
925
|
+
const tokens = new Set();
|
|
926
|
+
const rewritten = text.replace(/\{\{\s*proxy\.([^}]+?)\s*\}\}/g, (_match, proxyPath) => {
|
|
927
|
+
const normalized = proxyPath.trim();
|
|
928
|
+
tokens.add(`proxy.${normalized}`);
|
|
929
|
+
return `{{agent.${normalized}}}`;
|
|
930
|
+
});
|
|
931
|
+
if (tokens.size === 0) {
|
|
932
|
+
return { text, tokens: [] };
|
|
933
|
+
}
|
|
934
|
+
const hasResolutionNotice = rewritten.includes('## Agent Resolution Needed');
|
|
935
|
+
const finalText = hasResolutionNotice
|
|
936
|
+
? rewritten
|
|
937
|
+
: `${FraimLocalMCPServer.AGENT_RESOLUTION_NOTICE}\n\n${rewritten}`;
|
|
938
|
+
return { text: finalText, tokens: Array.from(tokens).sort() };
|
|
939
|
+
}
|
|
940
|
+
rewriteUnresolvedProxyPlaceholders(value) {
|
|
941
|
+
const rewrittenTokens = new Set();
|
|
942
|
+
const rewrite = (input) => {
|
|
943
|
+
if (typeof input === 'string') {
|
|
944
|
+
const rewritten = this.rewriteProxyTokensInText(input);
|
|
945
|
+
rewritten.tokens.forEach((token) => rewrittenTokens.add(token));
|
|
946
|
+
return rewritten.text;
|
|
947
|
+
}
|
|
948
|
+
if (Array.isArray(input)) {
|
|
949
|
+
return input.map(rewrite);
|
|
950
|
+
}
|
|
951
|
+
if (input && typeof input === 'object') {
|
|
952
|
+
const output = {};
|
|
953
|
+
for (const [key, nestedValue] of Object.entries(input)) {
|
|
954
|
+
if (key === 'text' && typeof nestedValue === 'string') {
|
|
955
|
+
const rewritten = this.rewriteProxyTokensInText(nestedValue);
|
|
956
|
+
rewritten.tokens.forEach((token) => rewrittenTokens.add(token));
|
|
957
|
+
output[key] = rewritten.text;
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
output[key] = rewrite(nestedValue);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return output;
|
|
964
|
+
}
|
|
965
|
+
return input;
|
|
966
|
+
};
|
|
967
|
+
const rewrittenValue = rewrite(value);
|
|
968
|
+
return { value: rewrittenValue, tokens: Array.from(rewrittenTokens).sort() };
|
|
969
|
+
}
|
|
970
|
+
substituteTemplates(content) {
|
|
971
|
+
return this.ensureEngine().substituteTemplates(content);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Initialize the LocalRegistryResolver for override resolution
|
|
975
|
+
*/
|
|
976
|
+
getRegistryResolver(requestSessionId) {
|
|
977
|
+
const projectRoot = this.findProjectRoot();
|
|
978
|
+
this.log(`🔍 getRegistryResolver: projectRoot = ${projectRoot}`);
|
|
979
|
+
if (!projectRoot) {
|
|
980
|
+
this.log('⚠️ No project root found, override resolution disabled');
|
|
981
|
+
// Return a resolver that always falls back to remote
|
|
982
|
+
return new local_registry_resolver_1.LocalRegistryResolver({
|
|
983
|
+
workspaceRoot: process.cwd(),
|
|
984
|
+
remoteContentResolver: async (_path) => {
|
|
985
|
+
throw new Error('No project root available');
|
|
986
|
+
},
|
|
987
|
+
shouldFilter: (content) => this.isStub(content)
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
return new local_registry_resolver_1.LocalRegistryResolver({
|
|
992
|
+
workspaceRoot: projectRoot,
|
|
993
|
+
shouldFilter: (content) => this.isStub(content),
|
|
994
|
+
remoteContentResolver: async (path) => {
|
|
995
|
+
// Fetch parent content from remote for inheritance
|
|
996
|
+
this.log(`🔄 Remote content resolver: fetching ${path}`);
|
|
997
|
+
this.log(`🔄 Fetching raw file content: ${path}`);
|
|
998
|
+
const request = {
|
|
999
|
+
jsonrpc: '2.0',
|
|
1000
|
+
id: (0, crypto_1.randomUUID)(),
|
|
1001
|
+
method: 'tools/call',
|
|
1002
|
+
params: {
|
|
1003
|
+
name: 'get_fraim_file',
|
|
1004
|
+
arguments: {
|
|
1005
|
+
path,
|
|
1006
|
+
raw: true
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
this.applyRequestSessionId(request, requestSessionId);
|
|
1011
|
+
const response = await this._doProxyToRemote(request);
|
|
1012
|
+
if (response.error) {
|
|
1013
|
+
this.logError(`❌ Remote content resolver failed: ${response.error.message}`);
|
|
1014
|
+
throw new Error(`Failed to fetch parent: ${response.error.message}`);
|
|
1015
|
+
}
|
|
1016
|
+
// Extract content from MCP response format
|
|
1017
|
+
if (response.result?.content && Array.isArray(response.result.content)) {
|
|
1018
|
+
const textContent = response.result.content.find((c) => c.type === 'text');
|
|
1019
|
+
if (textContent?.text) {
|
|
1020
|
+
this.log(`✅ Remote content resolver: fetched ${textContent.text.length} chars`);
|
|
1021
|
+
return textContent.text;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
this.logError(`❌ Remote content resolver: no content in response for ${path}`);
|
|
1025
|
+
this.logError(`Response: ${JSON.stringify(response, null, 2)}`);
|
|
1026
|
+
throw new Error(`No content in remote response for ${path}`);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Check if content is a "stub" indicating local version should be ignored.
|
|
1033
|
+
*/
|
|
1034
|
+
isStub(content) {
|
|
1035
|
+
return content.includes('<!-- FRAIM_DISCOVERY_STUB -->') ||
|
|
1036
|
+
content.includes('STUB:') ||
|
|
1037
|
+
content.includes('<!-- STUB -->');
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
if (existsSync(jobFilePath)) {
|
|
1041
|
+
return `jobs/${category}/${jobName}.md`;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
} catch {
|
|
1045
|
+
// Best effort scan only.
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Process template substitution in MCP response
|
|
1056
|
+
*/
|
|
1057
|
+
processResponse(response) {
|
|
1058
|
+
if (!response.result)
|
|
1059
|
+
return response;
|
|
1060
|
+
// Recursively substitute templates in all string values
|
|
1061
|
+
const processValue = (value) => {
|
|
1062
|
+
if (typeof value === 'string') {
|
|
1063
|
+
return this.substituteTemplates(value);
|
|
1064
|
+
}
|
|
1065
|
+
else if (Array.isArray(value)) {
|
|
1066
|
+
return value.map(processValue);
|
|
1067
|
+
}
|
|
1068
|
+
else if (value && typeof value === 'object') {
|
|
1069
|
+
const processed = {};
|
|
1070
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1071
|
+
processed[key] = processValue(val);
|
|
1072
|
+
}
|
|
1073
|
+
return processed;
|
|
1074
|
+
}
|
|
1075
|
+
return value;
|
|
1076
|
+
};
|
|
1077
|
+
return {
|
|
1078
|
+
...response,
|
|
1079
|
+
result: processValue(response.result)
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
applyAgentFallbackForUnresolvedProxy(response) {
|
|
1083
|
+
if (!response.result)
|
|
1084
|
+
return response;
|
|
1085
|
+
const rewritten = this.rewriteUnresolvedProxyPlaceholders(response.result);
|
|
1086
|
+
if (rewritten.tokens.length > 0) {
|
|
1087
|
+
this.logError(`[${FraimLocalMCPServer.FALLBACK_ALERT_MARKER}] Rewrote unresolved proxy placeholders to agent placeholders: ${rewritten.tokens.join(', ')}`);
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
...response,
|
|
1091
|
+
result: rewritten.value
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
extractSessionIdFromRequest(request) {
|
|
1095
|
+
const sessionIdFromParams = request.params?.sessionId;
|
|
1096
|
+
if (typeof sessionIdFromParams === 'string' && sessionIdFromParams.trim().length > 0) {
|
|
1097
|
+
return sessionIdFromParams.trim();
|
|
1098
|
+
}
|
|
1099
|
+
const sessionIdFromArgs = request.params?.arguments?.sessionId;
|
|
1100
|
+
if (typeof sessionIdFromArgs === 'string' && sessionIdFromArgs.trim().length > 0) {
|
|
1101
|
+
return sessionIdFromArgs.trim();
|
|
1102
|
+
}
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
applyRequestSessionId(request, requestSessionId) {
|
|
1106
|
+
if (!requestSessionId || requestSessionId.trim().length === 0)
|
|
1107
|
+
return;
|
|
1108
|
+
request.params = request.params || {};
|
|
1109
|
+
if (typeof request.params.sessionId !== 'string' || request.params.sessionId.trim().length === 0) {
|
|
1110
|
+
request.params.sessionId = requestSessionId;
|
|
1111
|
+
}
|
|
1112
|
+
request.params.arguments = request.params.arguments || {};
|
|
1113
|
+
if (typeof request.params.arguments.sessionId !== 'string' || request.params.arguments.sessionId.trim().length === 0) {
|
|
1114
|
+
request.params.arguments.sessionId = requestSessionId;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Get or create a local AI mentor instance for the current session.
|
|
1119
|
+
*/
|
|
1120
|
+
getMentor(requestSessionId) {
|
|
1121
|
+
const resolver = this.getRegistryResolver(requestSessionId);
|
|
1122
|
+
return new ai_mentor_1.AIMentor(resolver);
|
|
1123
|
+
}
|
|
1124
|
+
normalizeRepoContext(repo) {
|
|
1125
|
+
if (!repo || typeof repo !== 'object')
|
|
1126
|
+
return repo;
|
|
1127
|
+
const normalized = { ...repo };
|
|
1128
|
+
if (!normalized.provider && normalized.url) {
|
|
1129
|
+
normalized.provider = (0, provider_utils_1.detectProvider)(normalized.url);
|
|
1130
|
+
}
|
|
1131
|
+
if (normalized.provider !== 'gitlab')
|
|
1132
|
+
return normalized;
|
|
1133
|
+
if (!normalized.projectPath && typeof normalized.url === 'string') {
|
|
1134
|
+
const gitlabMatch = normalized.url.match(/gitlab[^\/:]*[\/:]([^?\s#]+?)(?:\.git)?$/i);
|
|
1135
|
+
if (gitlabMatch) {
|
|
1136
|
+
normalized.projectPath = gitlabMatch[1].replace(/^\/+/, '');
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (!normalized.name && typeof normalized.projectPath === 'string') {
|
|
1140
|
+
const segments = normalized.projectPath.split('/').filter(Boolean);
|
|
1141
|
+
normalized.name = segments[segments.length - 1] || normalized.name;
|
|
1142
|
+
normalized.namespace = normalized.namespace || segments.slice(0, -1).join('/');
|
|
1143
|
+
}
|
|
1144
|
+
if (normalized.projectPath || !normalized.namespace || !normalized.name)
|
|
1145
|
+
return normalized;
|
|
1146
|
+
normalized.projectPath = `${normalized.namespace}/${normalized.name}`;
|
|
1147
|
+
return normalized;
|
|
1148
|
+
}
|
|
1149
|
+
normalizeIssueTrackingContext(issueTracking) {
|
|
1150
|
+
if (!issueTracking || typeof issueTracking !== 'object')
|
|
1151
|
+
return issueTracking;
|
|
1152
|
+
if (issueTracking.provider !== 'gitlab')
|
|
1153
|
+
return issueTracking;
|
|
1154
|
+
if (issueTracking.projectPath || !issueTracking.namespace || !issueTracking.name)
|
|
1155
|
+
return issueTracking;
|
|
1156
|
+
return {
|
|
1157
|
+
...issueTracking,
|
|
1158
|
+
projectPath: `${issueTracking.namespace}/${issueTracking.name}`
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Internal method to perform the actual proxy request to the remote server.
|
|
1163
|
+
* This method does NOT inject raw: true, as it is used for both top-level
|
|
1164
|
+
* tool calls and recursive inclusion resolution.
|
|
1165
|
+
*/
|
|
1166
|
+
async _doProxyToRemote(request, requestId = (0, crypto_1.randomUUID)()) {
|
|
1167
|
+
try {
|
|
1168
|
+
// Special handling for fraim_connect - automatically inject machine and repo info
|
|
1169
|
+
if (request.method === 'tools/call' && request.params?.name === 'fraim_connect') {
|
|
1170
|
+
this.log(`[req:${requestId}] Intercepting fraim_connect to inject machine/repo info`);
|
|
1171
|
+
const args = request.params.arguments || {};
|
|
1172
|
+
// REQUIRED: Auto-detect and inject machine info
|
|
1173
|
+
const detectedMachine = this.detectMachineInfo();
|
|
1174
|
+
args.machine = {
|
|
1175
|
+
...args.machine, // Agent values as fallback
|
|
1176
|
+
...detectedMachine // Detected values override (always win)
|
|
1177
|
+
};
|
|
1178
|
+
this.log(`[req:${requestId}] Auto-detected and injected machine info: ${JSON.stringify(args.machine)}`);
|
|
1179
|
+
// REQUIRED: Auto-detect and inject repo info
|
|
1180
|
+
const detectedRepo = this.detectRepoInfo();
|
|
1181
|
+
if (detectedRepo) {
|
|
1182
|
+
args.repo = {
|
|
1183
|
+
...args.repo, // Agent values as fallback
|
|
1184
|
+
...detectedRepo // Detected values override (always win)
|
|
1185
|
+
};
|
|
1186
|
+
args.repo = this.normalizeRepoContext(args.repo);
|
|
1187
|
+
const repoLabel = args.repo.owner ? `${args.repo.owner}/${args.repo.name}` : args.repo.name;
|
|
1188
|
+
this.log(`[req:${requestId}] Auto-detected and injected repo info: ${repoLabel}`);
|
|
1189
|
+
}
|
|
1190
|
+
else {
|
|
1191
|
+
// If detection fails, use agent-provided values (if any)
|
|
1192
|
+
if (!args.repo || !args.repo.url) {
|
|
1193
|
+
// Only return error if agent didn't provide repo info either
|
|
1194
|
+
this.logError(`[req:${requestId}] Could not detect repo info and no repo info provided by agent`);
|
|
1195
|
+
return {
|
|
1196
|
+
jsonrpc: '2.0',
|
|
1197
|
+
id: request.id,
|
|
1198
|
+
error: {
|
|
1199
|
+
code: -32603,
|
|
1200
|
+
message: `Failed to detect repository information. Please ensure you are in a git repository or have ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('config.json')} configured with repository details, or provide repo info in fraim_connect arguments.`
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
args.repo = this.normalizeRepoContext(args.repo);
|
|
1205
|
+
const repoLabel = args.repo.owner ? `${args.repo.owner}/${args.repo.name}` : args.repo.name;
|
|
1206
|
+
this.log(`[req:${requestId}] Using agent-provided repo info: ${repoLabel}`);
|
|
1207
|
+
}
|
|
1208
|
+
const configuredIssueTracking = this.config?.issueTracking;
|
|
1209
|
+
if (configuredIssueTracking && typeof configuredIssueTracking === 'object') {
|
|
1210
|
+
args.issueTracking = {
|
|
1211
|
+
...(args.issueTracking || {}),
|
|
1212
|
+
...configuredIssueTracking
|
|
1213
|
+
};
|
|
1214
|
+
args.issueTracking = this.normalizeIssueTrackingContext(args.issueTracking);
|
|
1215
|
+
this.log(`[req:${requestId}] Applied issueTracking context: ${args.issueTracking.provider || 'unknown'}`);
|
|
1216
|
+
}
|
|
1217
|
+
else if (args.issueTracking) {
|
|
1218
|
+
args.issueTracking = this.normalizeIssueTrackingContext(args.issueTracking);
|
|
1219
|
+
}
|
|
1220
|
+
const runtimeRepoContext = {
|
|
1221
|
+
...args.repo,
|
|
1222
|
+
...(args.issueTracking ? { issueTracking: args.issueTracking } : {})
|
|
1223
|
+
};
|
|
1224
|
+
// Keep proxy runtime context in sync with connect payload.
|
|
1225
|
+
this.agentInfo = args.agent;
|
|
1226
|
+
this.machineInfo = args.machine;
|
|
1227
|
+
this.repoInfo = runtimeRepoContext;
|
|
1228
|
+
if (this.engine) {
|
|
1229
|
+
this.engine.setAgentInfo(this.agentInfo);
|
|
1230
|
+
this.engine.setMachineInfo(this.machineInfo);
|
|
1231
|
+
this.engine.setRepoInfo(this.repoInfo);
|
|
1232
|
+
}
|
|
1233
|
+
// In a proxy setup, the remote server resolves the API key ID during event upload.
|
|
1234
|
+
// No local resolution needed.
|
|
1235
|
+
// Update the request with injected info
|
|
1236
|
+
request.params.arguments = args;
|
|
1237
|
+
}
|
|
1238
|
+
const headers = {
|
|
1239
|
+
'Content-Type': 'application/json',
|
|
1240
|
+
'x-api-key': this.apiKey,
|
|
1241
|
+
'x-fraim-request-id': requestId,
|
|
1242
|
+
'x-fraim-local-version': this.localVersion
|
|
1243
|
+
};
|
|
1244
|
+
const sessionId = this.extractSessionIdFromRequest(request);
|
|
1245
|
+
if (sessionId) {
|
|
1246
|
+
headers[FraimLocalMCPServer.SESSION_HEADER] = sessionId;
|
|
1247
|
+
}
|
|
1248
|
+
this.log(`[req:${requestId}] Proxying ${request.method} to ${this.remoteUrl}/mcp`);
|
|
1249
|
+
// Resolve templates in the outgoing request so the remote server
|
|
1250
|
+
// only ever sees finalized values.
|
|
1251
|
+
const stringifiedRequest = JSON.stringify(request);
|
|
1252
|
+
const resolvedRequestStr = this.substituteTemplates(stringifiedRequest);
|
|
1253
|
+
const finalRequest = JSON.parse(resolvedRequestStr);
|
|
1254
|
+
const response = await axios_1.default.post(`${this.remoteUrl}/mcp`, finalRequest, {
|
|
1255
|
+
headers,
|
|
1256
|
+
timeout: 30000
|
|
1257
|
+
});
|
|
1258
|
+
return response.data;
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
const status = error?.response?.status;
|
|
1262
|
+
const remoteData = error?.response?.data;
|
|
1263
|
+
this.logError(`[req:${requestId}] Remote request failed (${status || 'no-status'}): ${error.message}`);
|
|
1264
|
+
if (remoteData && typeof remoteData === 'object') {
|
|
1265
|
+
const forwarded = {
|
|
1266
|
+
jsonrpc: typeof remoteData.jsonrpc === 'string' ? remoteData.jsonrpc : '2.0',
|
|
1267
|
+
id: remoteData.id ?? request.id,
|
|
1268
|
+
error: remoteData.error ?? {
|
|
1269
|
+
code: -32603,
|
|
1270
|
+
message: `Remote server error (${status || 'unknown status'}): ${error.message}`
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
if (forwarded.error && typeof forwarded.error === 'object') {
|
|
1274
|
+
const existingData = forwarded.error.data;
|
|
1275
|
+
forwarded.error.data = {
|
|
1276
|
+
...(existingData && typeof existingData === 'object' ? existingData : {}),
|
|
1277
|
+
fraimRequestId: requestId,
|
|
1278
|
+
remoteStatus: status ?? null,
|
|
1279
|
+
localMcpVersion: this.localVersion
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
return forwarded;
|
|
1283
|
+
}
|
|
1284
|
+
return {
|
|
1285
|
+
jsonrpc: '2.0',
|
|
1286
|
+
id: request.id || null,
|
|
1287
|
+
error: {
|
|
1288
|
+
code: status === 401 ? -32001 : -32603,
|
|
1289
|
+
message: `Remote server error (${status || 'unknown status'}): ${error.message}`,
|
|
1290
|
+
data: {
|
|
1291
|
+
fraimRequestId: requestId,
|
|
1292
|
+
remoteStatus: status ?? null,
|
|
1293
|
+
localMcpVersion: this.localVersion,
|
|
1294
|
+
remoteError: remoteData
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Try to request workspace roots from MCP client
|
|
1302
|
+
*/
|
|
1303
|
+
requestRootsFromClient() {
|
|
1304
|
+
if (!this.clientSupportsRoots || this.pendingRootsRequest) {
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
this.log('🔍 Requesting workspace roots from client...');
|
|
1309
|
+
this.pendingRootsRequest = true;
|
|
1310
|
+
// Send roots/list request to client via stdout
|
|
1311
|
+
const rootsRequest = {
|
|
1312
|
+
jsonrpc: '2.0',
|
|
1313
|
+
id: 'fraim-roots-query',
|
|
1314
|
+
method: 'roots/list',
|
|
1315
|
+
params: {}
|
|
1316
|
+
};
|
|
1317
|
+
this.log(`📤 Sending roots/list request: ${JSON.stringify(rootsRequest)}`);
|
|
1318
|
+
this.writer(JSON.stringify(rootsRequest) + '\n');
|
|
1319
|
+
}
|
|
1320
|
+
catch (error) {
|
|
1321
|
+
this.log(`⚠️ Failed to request roots: ${error.message}`);
|
|
1322
|
+
this.pendingRootsRequest = false;
|
|
1323
|
+
// Fall back to env var search
|
|
1324
|
+
this.loadConfig();
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Handle incoming MCP request
|
|
1329
|
+
*/
|
|
1330
|
+
async handleRequest(request) {
|
|
1331
|
+
this.log(`📥 ${request.method}`);
|
|
1332
|
+
const requestSessionId = this.extractSessionIdFromRequest(request);
|
|
1333
|
+
const requestId = (0, crypto_1.randomUUID)();
|
|
1334
|
+
// Special handling for initialize request
|
|
1335
|
+
if (request.method === 'initialize') {
|
|
1336
|
+
// Check if client supports roots
|
|
1337
|
+
const clientCapabilities = request.params?.capabilities;
|
|
1338
|
+
if (clientCapabilities?.roots) {
|
|
1339
|
+
this.clientSupportsRoots = true;
|
|
1340
|
+
this.log(`✅ Client supports roots capability (listChanged: ${clientCapabilities.roots.listChanged})`);
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
this.clientSupportsRoots = false;
|
|
1344
|
+
this.log(`❌ Client does NOT support roots capability`);
|
|
1345
|
+
}
|
|
1346
|
+
// Proxy initialize to remote server first using the raw proxy method
|
|
1347
|
+
const response = await this._doProxyToRemote(request, requestId);
|
|
1348
|
+
const processedResponse = await this.finalizeToolResponse(request, response, requestSessionId, requestId);
|
|
1349
|
+
// After successful initialization, load config
|
|
1350
|
+
if (!processedResponse.error) {
|
|
1351
|
+
// Load config immediately for compatibility, then request roots so
|
|
1352
|
+
// workspace-aware clients (Codex, Cursor, etc.) can provide exact roots.
|
|
1353
|
+
this.loadConfig();
|
|
1354
|
+
if (this.clientSupportsRoots) {
|
|
1355
|
+
setImmediate(() => this.requestRootsFromClient());
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
this.log(`📤 ${request.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
1359
|
+
return processedResponse;
|
|
1360
|
+
}
|
|
1361
|
+
// Force ALL tools/call requests to return raw definitions so the proxy
|
|
1362
|
+
// can resolve templates and replace includes locally
|
|
1363
|
+
const toolName = request.params?.name;
|
|
1364
|
+
const args = request.params?.arguments || {};
|
|
1365
|
+
let injectedRequest = request;
|
|
1366
|
+
if (request.method === 'tools/call' && typeof toolName === 'string') {
|
|
1367
|
+
// 🔍 SMART DISPATCHER: Intercept mentoring and job/workflow tools for local overrides
|
|
1368
|
+
if (toolName === 'seekMentoring') {
|
|
1369
|
+
try {
|
|
1370
|
+
const mentor = this.getMentor(requestSessionId);
|
|
1371
|
+
const tutoringResponse = await mentor.handleMentoringRequest(args);
|
|
1372
|
+
this.log(`✅ Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
|
|
1373
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
|
|
1377
|
+
// If local fails, we continue to proxy to the remote server
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (toolName === 'get_fraim_job') {
|
|
1381
|
+
const name = args.job;
|
|
1382
|
+
if (name) {
|
|
1383
|
+
try {
|
|
1384
|
+
const mentor = this.getMentor(requestSessionId);
|
|
1385
|
+
const overview = await mentor.getJobOverview(name);
|
|
1386
|
+
if (overview) {
|
|
1387
|
+
this.log(`✅ Local override found for get_fraim_job: ${name}`);
|
|
1388
|
+
let responseText = overview.overview;
|
|
1389
|
+
if (!overview.isSimple) {
|
|
1390
|
+
const phaseAuthority = await mentor.getPhaseAuthorityContent();
|
|
1391
|
+
if (phaseAuthority)
|
|
1392
|
+
responseText = `${phaseAuthority}\n\n---\n\n${responseText}`;
|
|
1393
|
+
responseText += `\n\n---\n\n**This job has phases.** Use \`seekMentoring\` to get phase-specific instructions.`;
|
|
1394
|
+
}
|
|
1395
|
+
// Inject local learning context for job requests (RFC 177).
|
|
1396
|
+
const userEmail = this.ensureEngine().getUserEmail();
|
|
1397
|
+
if (userEmail) {
|
|
1398
|
+
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
1399
|
+
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
|
|
1400
|
+
if (learningSection) {
|
|
1401
|
+
responseText += `\n\n---` + learningSection;
|
|
1402
|
+
this.log(`✅ Injected job-focus learning context for ${userEmail} (local override path)`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
catch (error) {
|
|
1409
|
+
this.log(`⚠️ Local get_fraim_job failed for ${name}: ${error.message}. Falling back to remote.`);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
// DISCOVERY AGGREGATION: Merge local and remote jobs
|
|
1414
|
+
if (toolName === 'list_fraim_jobs') {
|
|
1415
|
+
const response = await this._doProxyToRemote(request, requestId);
|
|
1416
|
+
if (!response.error && response.result?.content?.[0]?.text) {
|
|
1417
|
+
try {
|
|
1418
|
+
const resolver = this.getRegistryResolver(requestSessionId);
|
|
1419
|
+
const localItems = await resolver.listItems('job');
|
|
1420
|
+
if (localItems.length > 0) {
|
|
1421
|
+
this.log(`📦 Aggregating ${localItems.length} local jobs into remote response`);
|
|
1422
|
+
let combinedText = response.result.content[0].text;
|
|
1423
|
+
combinedText += `\n\n## Local & Personalized Jobs (${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)()})\n\n`;
|
|
1424
|
+
for (const item of localItems) {
|
|
1425
|
+
combinedText += `- **${item.name}**: ${item.description || '(No description available)'}\n`;
|
|
1426
|
+
}
|
|
1427
|
+
response.result.content[0].text = combinedText;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
catch (error) {
|
|
1431
|
+
this.log(`⚠️ Discovery aggregation failed: ${error.message}`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return response;
|
|
1435
|
+
}
|
|
1436
|
+
// Normal path for other tools: inject raw:true
|
|
1437
|
+
injectedRequest = {
|
|
1438
|
+
...request,
|
|
1439
|
+
params: {
|
|
1440
|
+
...request.params,
|
|
1441
|
+
arguments: {
|
|
1442
|
+
...args,
|
|
1443
|
+
raw: true
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
const response = await this._doProxyToRemote(injectedRequest, requestId);
|
|
1449
|
+
const processedResponse = await this.finalizeToolResponse(injectedRequest, response, requestSessionId, requestId);
|
|
1450
|
+
this.log(`📤 ${injectedRequest.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
1451
|
+
return processedResponse;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Handle incoming MCP response (to our roots/list request)
|
|
1455
|
+
*/
|
|
1456
|
+
handleResponse(response) {
|
|
1457
|
+
// Check if this is a response to our roots/list request
|
|
1458
|
+
if (response.id === 'fraim-roots-query') {
|
|
1459
|
+
this.log(`📥 Response to roots/list request`);
|
|
1460
|
+
this.pendingRootsRequest = false;
|
|
1461
|
+
if (response.error) {
|
|
1462
|
+
this.log(`⚠️ Client returned error for roots/list: ${response.error.message}`);
|
|
1463
|
+
// Fall back to env var + upward search
|
|
1464
|
+
this.loadConfig();
|
|
1465
|
+
}
|
|
1466
|
+
else {
|
|
1467
|
+
const roots = response.result?.roots;
|
|
1468
|
+
if (roots && Array.isArray(roots) && roots.length > 0) {
|
|
1469
|
+
// Use the first root (typically the primary workspace folder)
|
|
1470
|
+
const firstRoot = roots[0];
|
|
1471
|
+
if (firstRoot.uri && firstRoot.uri.startsWith('file://')) {
|
|
1472
|
+
const rootPath = this.fileUriToLocalPath(firstRoot.uri);
|
|
1473
|
+
if (rootPath) {
|
|
1474
|
+
this.workspaceRoot = rootPath;
|
|
1475
|
+
this.log(`✅ Got workspace root from client: ${this.workspaceRoot} (${firstRoot.name || 'unknown'})`);
|
|
1476
|
+
this.loadConfig(); // Reload config with the correct workspace root
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
this.log(`⚠️ Could not parse root URI: ${firstRoot.uri}`);
|
|
1480
|
+
this.loadConfig();
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
else {
|
|
1484
|
+
this.log(`⚠️ Client returned invalid root URI: ${firstRoot.uri}`);
|
|
1485
|
+
this.loadConfig();
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
this.log(`⚠️ Client returned empty or invalid roots array`);
|
|
1490
|
+
this.loadConfig();
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Convert a file:// URI to a local filesystem path.
|
|
1497
|
+
*/
|
|
1498
|
+
fileUriToLocalPath(uri) {
|
|
1499
|
+
if (!uri.startsWith('file://'))
|
|
1500
|
+
return null;
|
|
1501
|
+
let pathname = '';
|
|
1502
|
+
let host = '';
|
|
1503
|
+
try {
|
|
1504
|
+
const parsed = new URL(uri);
|
|
1505
|
+
if (parsed.protocol !== 'file:')
|
|
1506
|
+
return null;
|
|
1507
|
+
pathname = parsed.pathname || '';
|
|
1508
|
+
host = parsed.host || '';
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
const raw = uri.replace('file://', '');
|
|
1512
|
+
try {
|
|
1513
|
+
pathname = decodeURIComponent(raw);
|
|
1514
|
+
}
|
|
1515
|
+
catch {
|
|
1516
|
+
pathname = raw;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
try {
|
|
1520
|
+
pathname = decodeURIComponent(pathname);
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
// Keep raw path if decoding fails
|
|
1524
|
+
}
|
|
1525
|
+
let localPath = pathname;
|
|
1526
|
+
if (host && host !== 'localhost') {
|
|
1527
|
+
localPath = `//${host}${localPath}`;
|
|
1528
|
+
}
|
|
1529
|
+
// Windows drive letters can arrive as /C:/... or /c:/... or decoded from /c%3A/...
|
|
1530
|
+
localPath = localPath.replace(/^\/([a-zA-Z]:)/, '$1');
|
|
1531
|
+
if (process.platform === 'win32') {
|
|
1532
|
+
localPath = localPath.replace(/\//g, '\\');
|
|
1533
|
+
}
|
|
1534
|
+
return localPath;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Start STDIO server
|
|
1538
|
+
*/
|
|
1539
|
+
start() {
|
|
1540
|
+
let buffer = '';
|
|
1541
|
+
process.stdin.setEncoding('utf8');
|
|
1542
|
+
// Set up periodic usage data upload
|
|
1543
|
+
const uploadInterval = setInterval(() => {
|
|
1544
|
+
this.uploadUsageData().catch(error => {
|
|
1545
|
+
this.log(`⚠️ Failed to upload usage data: ${error.message}`);
|
|
1546
|
+
});
|
|
1547
|
+
}, 60000); // Upload every minute
|
|
1548
|
+
// Clean up interval on shutdown
|
|
1549
|
+
const cleanup = () => {
|
|
1550
|
+
clearInterval(uploadInterval);
|
|
1551
|
+
this.usageCollector.shutdown();
|
|
1552
|
+
};
|
|
1553
|
+
process.stdin.on('data', async (chunk) => {
|
|
1554
|
+
buffer += chunk;
|
|
1555
|
+
// Process complete JSON-RPC messages (newline-delimited)
|
|
1556
|
+
let newlineIndex;
|
|
1557
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
1558
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
1559
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
1560
|
+
if (line) {
|
|
1561
|
+
try {
|
|
1562
|
+
const message = JSON.parse(line);
|
|
1563
|
+
// Distinguish between requests and responses
|
|
1564
|
+
// Requests have 'method', responses have 'result' or 'error' (but no 'method')
|
|
1565
|
+
if ('method' in message && message.method) {
|
|
1566
|
+
// This is a request from the client
|
|
1567
|
+
const response = await this.handleRequest(message);
|
|
1568
|
+
// Only send response if we got one (null means we handled it internally)
|
|
1569
|
+
if (response) {
|
|
1570
|
+
// Collect usage for all tools/call requests before sending response
|
|
1571
|
+
this.collectUsageForResponse(message, response);
|
|
1572
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
else if ('result' in message || 'error' in message) {
|
|
1576
|
+
// This is a response from the client (to our roots/list request)
|
|
1577
|
+
this.handleResponse(message);
|
|
1578
|
+
// Don't send anything back - this was a response to our request
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
this.logError(`Unknown message type: ${JSON.stringify(message)}`);
|
|
1582
|
+
process.stdout.write(JSON.stringify({
|
|
1583
|
+
jsonrpc: '2.0',
|
|
1584
|
+
error: {
|
|
1585
|
+
code: -32600,
|
|
1586
|
+
message: 'Invalid Request: Unknown message type'
|
|
1587
|
+
},
|
|
1588
|
+
id: message.id || null
|
|
1589
|
+
}) + '\n');
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
catch (error) {
|
|
1593
|
+
this.logError(`Message processing failed: ${error.message}`);
|
|
1594
|
+
// Send error response
|
|
1595
|
+
const errorResponse = {
|
|
1596
|
+
jsonrpc: '2.0',
|
|
1597
|
+
error: {
|
|
1598
|
+
code: -32700,
|
|
1599
|
+
message: `Parse error: ${error.message}`
|
|
1600
|
+
},
|
|
1601
|
+
id: null
|
|
1602
|
+
};
|
|
1603
|
+
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
process.stdin.on('end', () => {
|
|
1609
|
+
this.log('🛑 Stdin closed, shutting down...');
|
|
1610
|
+
cleanup();
|
|
1611
|
+
process.exit(0);
|
|
1612
|
+
});
|
|
1613
|
+
process.on('SIGTERM', () => {
|
|
1614
|
+
this.log('🛑 SIGTERM received, shutting down...');
|
|
1615
|
+
cleanup();
|
|
1616
|
+
process.exit(0);
|
|
1617
|
+
});
|
|
1618
|
+
process.on('SIGINT', () => {
|
|
1619
|
+
this.log('🛑 SIGINT received, shutting down...');
|
|
1620
|
+
cleanup();
|
|
1621
|
+
process.exit(0);
|
|
1622
|
+
});
|
|
1623
|
+
this.log('✅ FRAIM Local MCP Server ready');
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Collect usage analytics for tools/call requests
|
|
1627
|
+
*/
|
|
1628
|
+
collectUsageForResponse(request, response) {
|
|
1629
|
+
// Only collect usage for tools/call requests
|
|
1630
|
+
if (request.method !== 'tools/call') {
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const toolName = request.params?.name;
|
|
1634
|
+
const args = request.params?.arguments || {};
|
|
1635
|
+
const requestSessionId = this.extractSessionIdFromRequest(request);
|
|
1636
|
+
if (toolName && requestSessionId) {
|
|
1637
|
+
const success = !response.error;
|
|
1638
|
+
this.log(`📊 Collecting usage: ${toolName} (session: ${requestSessionId}, success: ${success})`);
|
|
1639
|
+
// Capture the current queue size before collection
|
|
1640
|
+
const beforeCount = this.usageCollector.getEventCount();
|
|
1641
|
+
try {
|
|
1642
|
+
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success);
|
|
1643
|
+
// Check if the event was actually added to the queue
|
|
1644
|
+
const afterCount = this.usageCollector.getEventCount();
|
|
1645
|
+
if (afterCount > beforeCount) {
|
|
1646
|
+
this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
|
|
1650
|
+
// Log the args for debugging path parsing issues
|
|
1651
|
+
if (toolName === 'get_fraim_file' && args.path) {
|
|
1652
|
+
this.log(`📊 🔍 Debug: get_fraim_file path="${args.path}"`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
this.log(`📊 ❌ Usage collection error: ${error.message}`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
else if (request.method === 'tools/call') {
|
|
1661
|
+
this.log(`⚠️ Skipping usage collection: toolName=${toolName}, sessionId=${requestSessionId}`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Flush collected usage data to the remote server
|
|
1666
|
+
*/
|
|
1667
|
+
async uploadUsageData() {
|
|
1668
|
+
const eventCount = this.usageCollector.getEventCount();
|
|
1669
|
+
this.log(`📊 Upload check: ${eventCount} events queued`);
|
|
1670
|
+
if (eventCount === 0) {
|
|
1671
|
+
return; // Nothing to flush
|
|
1672
|
+
}
|
|
1673
|
+
try {
|
|
1674
|
+
this.log(`📊 Attempting to flush ${eventCount} events to ${this.remoteUrl}/api/analytics/events`);
|
|
1675
|
+
await this.usageCollector.flush(this.remoteUrl, this.apiKey);
|
|
1676
|
+
this.log(`📊 ✅ Successfully flushed ${eventCount} usage events to remote server`);
|
|
1677
|
+
}
|
|
1678
|
+
catch (error) {
|
|
1679
|
+
this.log(`📊 ❌ Usage flushing error: ${error.message}`);
|
|
1680
|
+
// Log additional details for debugging
|
|
1681
|
+
if (error.response) {
|
|
1682
|
+
this.log(`📊 🔍 HTTP Status: ${error.response.status}, Data: ${JSON.stringify(error.response.data)}`);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
exports.FraimLocalMCPServer = FraimLocalMCPServer;
|
|
1688
|
+
FraimLocalMCPServer.AGENT_RESOLUTION_NOTICE = [
|
|
1689
|
+
'## Agent Resolution Needed',
|
|
1690
|
+
'Replace all `agent.*` placeholders with the best value from repository context, user intent, and available project config.'
|
|
1691
|
+
].join('\n');
|
|
1692
|
+
FraimLocalMCPServer.FALLBACK_ALERT_MARKER = 'PROXY_FALLBACK_ALERT';
|
|
1693
|
+
FraimLocalMCPServer.SESSION_HEADER = 'x-fraim-session-id';
|
|
1694
|
+
// Start server if run directly
|
|
1695
|
+
if (require.main === module) {
|
|
1696
|
+
const server = new FraimLocalMCPServer();
|
|
1697
|
+
server.start();
|
|
1698
|
+
}
|