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.
Files changed (70) hide show
  1. package/README.md +445 -0
  2. package/bin/fraim.js +23 -0
  3. package/dist/src/cli/api/get-provider-client.js +41 -0
  4. package/dist/src/cli/api/provider-client.js +107 -0
  5. package/dist/src/cli/commands/add-ide.js +430 -0
  6. package/dist/src/cli/commands/add-provider.js +233 -0
  7. package/dist/src/cli/commands/doctor.js +149 -0
  8. package/dist/src/cli/commands/init-project.js +301 -0
  9. package/dist/src/cli/commands/list-overridable.js +184 -0
  10. package/dist/src/cli/commands/list.js +57 -0
  11. package/dist/src/cli/commands/login.js +84 -0
  12. package/dist/src/cli/commands/mcp.js +15 -0
  13. package/dist/src/cli/commands/migrate-project-fraim.js +42 -0
  14. package/dist/src/cli/commands/override.js +177 -0
  15. package/dist/src/cli/commands/setup.js +651 -0
  16. package/dist/src/cli/commands/sync.js +162 -0
  17. package/dist/src/cli/commands/test-mcp.js +171 -0
  18. package/dist/src/cli/doctor/check-runner.js +199 -0
  19. package/dist/src/cli/doctor/checks/global-setup-checks.js +220 -0
  20. package/dist/src/cli/doctor/checks/ide-config-checks.js +250 -0
  21. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +381 -0
  22. package/dist/src/cli/doctor/checks/project-setup-checks.js +282 -0
  23. package/dist/src/cli/doctor/checks/scripts-checks.js +157 -0
  24. package/dist/src/cli/doctor/checks/workflow-checks.js +251 -0
  25. package/dist/src/cli/doctor/reporters/console-reporter.js +96 -0
  26. package/dist/src/cli/doctor/reporters/json-reporter.js +11 -0
  27. package/dist/src/cli/doctor/types.js +6 -0
  28. package/dist/src/cli/fraim.js +100 -0
  29. package/dist/src/cli/internal/device-flow-service.js +83 -0
  30. package/dist/src/cli/mcp/ide-formats.js +243 -0
  31. package/dist/src/cli/mcp/mcp-server-builder.js +48 -0
  32. package/dist/src/cli/mcp/mcp-server-registry.js +160 -0
  33. package/dist/src/cli/mcp/types.js +3 -0
  34. package/dist/src/cli/providers/local-provider-registry.js +166 -0
  35. package/dist/src/cli/providers/provider-registry.js +230 -0
  36. package/dist/src/cli/setup/auto-mcp-setup.js +331 -0
  37. package/dist/src/cli/setup/codex-local-config.js +37 -0
  38. package/dist/src/cli/setup/first-run.js +242 -0
  39. package/dist/src/cli/setup/ide-detector.js +179 -0
  40. package/dist/src/cli/setup/mcp-config-generator.js +192 -0
  41. package/dist/src/cli/setup/provider-prompts.js +339 -0
  42. package/dist/src/cli/utils/agent-adapters.js +126 -0
  43. package/dist/src/cli/utils/digest-utils.js +47 -0
  44. package/dist/src/cli/utils/fraim-gitignore.js +40 -0
  45. package/dist/src/cli/utils/platform-detection.js +258 -0
  46. package/dist/src/cli/utils/project-bootstrap.js +93 -0
  47. package/dist/src/cli/utils/remote-sync.js +315 -0
  48. package/dist/src/cli/utils/script-sync-utils.js +221 -0
  49. package/dist/src/cli/utils/version-utils.js +32 -0
  50. package/dist/src/core/ai-mentor.js +230 -0
  51. package/dist/src/core/config-loader.js +114 -0
  52. package/dist/src/core/config-writer.js +75 -0
  53. package/dist/src/core/types.js +23 -0
  54. package/dist/src/core/utils/git-utils.js +95 -0
  55. package/dist/src/core/utils/include-resolver.js +92 -0
  56. package/dist/src/core/utils/inheritance-parser.js +288 -0
  57. package/dist/src/core/utils/job-parser.js +176 -0
  58. package/dist/src/core/utils/local-registry-resolver.js +616 -0
  59. package/dist/src/core/utils/object-utils.js +11 -0
  60. package/dist/src/core/utils/project-fraim-migration.js +103 -0
  61. package/dist/src/core/utils/project-fraim-paths.js +38 -0
  62. package/dist/src/core/utils/provider-utils.js +18 -0
  63. package/dist/src/core/utils/server-startup.js +34 -0
  64. package/dist/src/core/utils/stub-generator.js +147 -0
  65. package/dist/src/core/utils/workflow-parser.js +174 -0
  66. package/dist/src/local-mcp-server/learning-context-builder.js +229 -0
  67. package/dist/src/local-mcp-server/stdio-server.js +1698 -0
  68. package/dist/src/local-mcp-server/usage-collector.js +264 -0
  69. package/index.js +85 -0
  70. 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
+ }