fraim-framework 2.0.177 → 2.0.180

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 (77) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/ai-hub/server.js +50 -1
  3. package/dist/src/api/admin/payments.js +33 -0
  4. package/dist/src/api/admin/sales-leads.js +21 -0
  5. package/dist/src/api/payment/create-session.js +338 -0
  6. package/dist/src/api/payment/dashboard-link.js +149 -0
  7. package/dist/src/api/payment/session-details.js +31 -0
  8. package/dist/src/api/payment/webhook.js +587 -0
  9. package/dist/src/api/personas/me.js +29 -0
  10. package/dist/src/api/pricing/get-config.js +25 -0
  11. package/dist/src/api/sales/contact.js +44 -0
  12. package/dist/src/cli/commands/add-provider.js +74 -61
  13. package/dist/src/cli/commands/add-surface.js +128 -0
  14. package/dist/src/cli/commands/login.js +5 -69
  15. package/dist/src/cli/commands/setup.js +27 -347
  16. package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
  17. package/dist/src/cli/fraim.js +2 -0
  18. package/dist/src/cli/mcp/ide-formats.js +5 -3
  19. package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
  20. package/dist/src/cli/providers/local-provider-registry.js +2 -3
  21. package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
  22. package/dist/src/cli/setup/ide-detector.js +34 -14
  23. package/dist/src/config/persona-capability-bundles.js +17 -13
  24. package/dist/src/db/payment-repository.js +61 -0
  25. package/dist/src/first-run/session-service.js +2 -2
  26. package/dist/src/fraim/config-loader.js +11 -0
  27. package/dist/src/fraim/db-service.js +2387 -0
  28. package/dist/src/fraim/issues.js +152 -0
  29. package/dist/src/fraim/template-processor.js +184 -0
  30. package/dist/src/fraim/utils/request-utils.js +23 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +28 -4
  32. package/dist/src/local-mcp-server/usage-collector.js +24 -0
  33. package/dist/src/middleware/auth.js +266 -0
  34. package/dist/src/middleware/cors-config.js +111 -0
  35. package/dist/src/middleware/logger.js +116 -0
  36. package/dist/src/middleware/rate-limit.js +110 -0
  37. package/dist/src/middleware/reject-query-api-key.js +45 -0
  38. package/dist/src/middleware/security-headers.js +41 -0
  39. package/dist/src/middleware/telemetry.js +134 -0
  40. package/dist/src/models/payment.js +2 -0
  41. package/dist/src/routes/analytics.js +1447 -0
  42. package/dist/src/routes/app-routes.js +32 -0
  43. package/dist/src/routes/auth-routes.js +505 -0
  44. package/dist/src/routes/oauth-routes.js +325 -0
  45. package/dist/src/routes/payment-routes.js +186 -0
  46. package/dist/src/routes/persona-catalog-routes.js +84 -0
  47. package/dist/src/services/admin-service.js +229 -0
  48. package/dist/src/services/audit-log-persistence.js +60 -0
  49. package/dist/src/services/audit-log.js +69 -0
  50. package/dist/src/services/cookie-service.js +129 -0
  51. package/dist/src/services/dashboard-access.js +27 -0
  52. package/dist/src/services/demo-seed-service.js +139 -0
  53. package/dist/src/services/email-code.js +23 -0
  54. package/dist/src/services/email-service-clean.js +782 -0
  55. package/dist/src/services/email-service.js +951 -0
  56. package/dist/src/services/installer-service.js +131 -0
  57. package/dist/src/services/mcp-oauth-store.js +33 -0
  58. package/dist/src/services/mcp-service.js +823 -0
  59. package/dist/src/services/oauth-helpers.js +127 -0
  60. package/dist/src/services/org-service.js +89 -0
  61. package/dist/src/services/persona-entitlement-service.js +288 -0
  62. package/dist/src/services/provider-service.js +215 -0
  63. package/dist/src/services/registry-service.js +628 -0
  64. package/dist/src/services/session-service.js +86 -0
  65. package/dist/src/services/trial-reminder-service.js +120 -0
  66. package/dist/src/services/usage-analytics-service.js +419 -0
  67. package/dist/src/services/workspace-identity.js +21 -0
  68. package/dist/src/types/analytics.js +2 -0
  69. package/dist/src/utils/payment-calculator.js +52 -0
  70. package/extensions/office-word/favicon.ico +0 -0
  71. package/extensions/office-word/icon-64.png +0 -0
  72. package/extensions/office-word/manifest.xml +33 -0
  73. package/extensions/office-word/taskpane.html +242 -0
  74. package/package.json +14 -3
  75. package/public/ai-hub/index.html +14 -2
  76. package/public/ai-hub/script.js +340 -66
  77. package/public/ai-hub/styles.css +83 -0
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.fileFraimIssue = fileFraimIssue;
7
+ exports.listFraimIssuesByReporter = listFraimIssuesByReporter;
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const FRAIM_REPO_OWNER = 'mathursrus';
10
+ const FRAIM_REPO_NAME = 'FRAIM';
11
+ const DEFAULT_LIST_LIMIT = 20;
12
+ const MAX_LIST_LIMIT = 100;
13
+ function normalizeLimit(limit) {
14
+ if (!Number.isFinite(limit))
15
+ return DEFAULT_LIST_LIMIT;
16
+ const parsed = Math.trunc(limit);
17
+ if (parsed <= 0)
18
+ return DEFAULT_LIST_LIMIT;
19
+ return Math.min(parsed, MAX_LIST_LIMIT);
20
+ }
21
+ /**
22
+ * File an issue in the FRAIM repository
23
+ *
24
+ * This function creates issues in the FRAIM repository for bug reports,
25
+ * feature requests, and workflow contributions.
26
+ */
27
+ async function fileFraimIssue(params) {
28
+ const { title, labels, dryRun, reporterEmail } = params;
29
+ // Prepend reporter line so the close-the-loop job can identify who filed the issue
30
+ const reporterLine = reporterEmail ? `**Reported by:** ${reporterEmail}\n\n` : '';
31
+ const body = reporterLine + params.body;
32
+ // Always target the FRAIM repository
33
+ const owner = FRAIM_REPO_OWNER;
34
+ const repo = FRAIM_REPO_NAME;
35
+ if (dryRun) {
36
+ return {
37
+ success: true,
38
+ dryRun: true,
39
+ message: `[DRY RUN] Would create GitHub issue: "${title}" in ${owner}/${repo}.`
40
+ };
41
+ }
42
+ try {
43
+ // Check for GitHub token
44
+ const token = process.env.GITHUB_TOKEN;
45
+ if (!token) {
46
+ return {
47
+ success: false,
48
+ message: `GitHub integration requires GITHUB_TOKEN environment variable.
49
+
50
+ Please set GITHUB_TOKEN environment variable to file issues in ${owner}/${repo}.`
51
+ };
52
+ }
53
+ // Create the issue in FRAIM repository
54
+ const url = `https://api.github.com/repos/${owner}/${repo}/issues`;
55
+ const payload = { title, body };
56
+ if (labels && labels.length > 0) {
57
+ payload.labels = labels;
58
+ }
59
+ const response = await axios_1.default.post(url, payload, {
60
+ headers: {
61
+ 'Authorization': `Bearer ${token}`,
62
+ 'Accept': 'application/vnd.github.v3+json',
63
+ 'Content-Type': 'application/json',
64
+ },
65
+ });
66
+ return {
67
+ success: true,
68
+ issueNumber: response.data.number,
69
+ htmlUrl: response.data.html_url
70
+ };
71
+ }
72
+ catch (error) {
73
+ let errorMessage = 'Unknown error';
74
+ if (axios_1.default.isAxiosError(error)) {
75
+ errorMessage = `Status: ${error.response?.status} - ${JSON.stringify(error.response?.data)}`;
76
+ }
77
+ else if (error instanceof Error) {
78
+ errorMessage = error.message;
79
+ }
80
+ return {
81
+ success: false,
82
+ message: `Failed to create issue in FRAIM repository: ${errorMessage}`
83
+ };
84
+ }
85
+ }
86
+ /**
87
+ * List issues filed into the FRAIM repository by a specific reporter email.
88
+ *
89
+ * Uses GitHub search against the existing "Reported by:" marker in issue bodies
90
+ * so we do not need a second FRAIM-owned index.
91
+ */
92
+ async function listFraimIssuesByReporter(params) {
93
+ const { reporterEmail } = params;
94
+ const limit = normalizeLimit(params.limit);
95
+ if (!reporterEmail) {
96
+ return {
97
+ success: false,
98
+ message: 'Reporter email is required to list FRAIM GitHub issues.'
99
+ };
100
+ }
101
+ const token = process.env.GITHUB_TOKEN;
102
+ if (!token) {
103
+ return {
104
+ success: false,
105
+ message: `GitHub integration requires GITHUB_TOKEN environment variable.
106
+
107
+ Please set GITHUB_TOKEN environment variable to query issues in ${FRAIM_REPO_OWNER}/${FRAIM_REPO_NAME}.`
108
+ };
109
+ }
110
+ const query = `repo:${FRAIM_REPO_OWNER}/${FRAIM_REPO_NAME} is:issue "Reported by: ${reporterEmail}" in:body`;
111
+ try {
112
+ const response = await axios_1.default.get('https://api.github.com/search/issues', {
113
+ headers: {
114
+ 'Authorization': `Bearer ${token}`,
115
+ 'Accept': 'application/vnd.github+json',
116
+ 'X-GitHub-Api-Version': '2022-11-28'
117
+ },
118
+ params: {
119
+ q: query,
120
+ sort: 'created',
121
+ order: 'desc',
122
+ per_page: limit,
123
+ page: 1
124
+ }
125
+ });
126
+ const issues = Array.isArray(response.data?.items)
127
+ ? response.data.items.map((item) => ({
128
+ issueNumber: item.number,
129
+ title: item.title,
130
+ status: item.state === 'closed' ? 'closed' : 'open',
131
+ createdAt: item.created_at
132
+ }))
133
+ : [];
134
+ return {
135
+ success: true,
136
+ issues
137
+ };
138
+ }
139
+ catch (error) {
140
+ let errorMessage = 'Unknown error';
141
+ if (axios_1.default.isAxiosError(error)) {
142
+ errorMessage = `Status: ${error.response?.status} - ${JSON.stringify(error.response?.data)}`;
143
+ }
144
+ else if (error instanceof Error) {
145
+ errorMessage = error.message;
146
+ }
147
+ return {
148
+ success: false,
149
+ message: `Failed to query issues in the FRAIM repository: ${errorMessage}`
150
+ };
151
+ }
152
+ }
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TemplateEngine = void 0;
4
+ exports.getTemplateEngine = getTemplateEngine;
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const provider_utils_1 = require("../core/utils/provider-utils");
8
+ const object_utils_1 = require("../core/utils/object-utils");
9
+ /**
10
+ * Multi-Provider Template Engine
11
+ * Processes workflow templates with provider-specific action mappings
12
+ */
13
+ class TemplateEngine {
14
+ constructor(registryPath) {
15
+ this.providers = new Map();
16
+ this.registryPath = registryPath || (0, path_1.join)(__dirname, '../../registry');
17
+ this.loadProviders();
18
+ }
19
+ loadProviders() {
20
+ try {
21
+ // Load GitHub provider templates
22
+ const githubPath = (0, path_1.join)(this.registryPath, 'providers/github.json');
23
+ if ((0, fs_1.existsSync)(githubPath)) {
24
+ const githubTemplates = JSON.parse((0, fs_1.readFileSync)(githubPath, 'utf-8'));
25
+ this.providers.set('github', githubTemplates);
26
+ }
27
+ // Load ADO provider templates
28
+ const adoPath = (0, path_1.join)(this.registryPath, 'providers/ado.json');
29
+ if ((0, fs_1.existsSync)(adoPath)) {
30
+ const adoTemplates = JSON.parse((0, fs_1.readFileSync)(adoPath, 'utf-8'));
31
+ this.providers.set('ado', adoTemplates);
32
+ }
33
+ // Load Jira provider templates
34
+ const jiraPath = (0, path_1.join)(this.registryPath, 'providers/jira.json');
35
+ if ((0, fs_1.existsSync)(jiraPath)) {
36
+ const jiraTemplates = JSON.parse((0, fs_1.readFileSync)(jiraPath, 'utf-8'));
37
+ this.providers.set('jira', jiraTemplates);
38
+ }
39
+ // Load GitLab provider templates
40
+ const gitlabPath = (0, path_1.join)(this.registryPath, 'providers/gitlab.json');
41
+ if ((0, fs_1.existsSync)(gitlabPath)) {
42
+ const gitlabTemplates = JSON.parse((0, fs_1.readFileSync)(gitlabPath, 'utf-8'));
43
+ this.providers.set('gitlab', gitlabTemplates);
44
+ }
45
+ console.log(`✅ Loaded ${this.providers.size} provider templates`);
46
+ }
47
+ catch (error) {
48
+ console.warn('⚠️ Failed to load provider templates:', error);
49
+ }
50
+ }
51
+ /**
52
+ * Process workflow content with provider actions only
53
+ */
54
+ processWorkflow(workflowContent, repositoryInfo) {
55
+ // Only process provider actions, no client-side config variables
56
+ return this.processProviderActions(workflowContent, repositoryInfo);
57
+ }
58
+ processProviderActions(workflowContent, repositoryInfo) {
59
+ // Determine code provider from repository info using shared utility
60
+ const repoData = repositoryInfo?.repository || repositoryInfo;
61
+ const codeProvider = repoData?.provider || (0, provider_utils_1.detectProvider)(repoData?.url);
62
+ // Determine issue provider from explicit issueTracking context only
63
+ const issueProvider = repositoryInfo?.issueTracking?.provider;
64
+ // Get templates for both providers
65
+ const codeTemplates = this.providers.get(codeProvider);
66
+ const issueTemplates = issueProvider ? this.providers.get(issueProvider) : undefined;
67
+ if (!codeTemplates && !issueTemplates) {
68
+ console.warn(`⚠️ No templates found for providers: code=${codeProvider}, issue=${issueProvider}`);
69
+ return workflowContent;
70
+ }
71
+ // Process with both providers
72
+ return this.processWithSplitProviders(workflowContent, codeProvider, issueProvider, repositoryInfo);
73
+ }
74
+ processWithSplitProviders(workflowContent, codeProvider, issueProvider, repositoryInfo) {
75
+ const codeTemplates = this.providers.get(codeProvider);
76
+ const issueTemplates = issueProvider ? this.providers.get(issueProvider) : undefined;
77
+ let processed = workflowContent;
78
+ // Define which actions are code-related vs issue-related
79
+ const issueActions = new Set([
80
+ 'get_issue', 'update_issue_status', 'add_issue_comment',
81
+ 'create_issue', 'assign_issue', 'search_issues',
82
+ 'close_issue', 'list_issues'
83
+ ]);
84
+ const codeActions = new Set([
85
+ 'create_pr', 'get_pr', 'get_pr_comments', 'get_pr_review_comments',
86
+ 'get_pr_reviews', 'add_pr_comment', 'merge_pr', 'create_branch',
87
+ 'switch_branch', 'commit_changes', 'push_branch'
88
+ ]);
89
+ // Replace issue actions with issue provider templates
90
+ if (issueTemplates) {
91
+ for (const [action, template] of Object.entries(issueTemplates)) {
92
+ if (issueActions.has(action)) {
93
+ const regex = new RegExp(`{{${action}}}`, 'g');
94
+ const renderedTemplate = this.renderTemplate(template, repositoryInfo);
95
+ processed = processed.replace(regex, renderedTemplate);
96
+ }
97
+ }
98
+ }
99
+ // Replace code actions with code provider templates
100
+ if (codeTemplates) {
101
+ for (const [action, template] of Object.entries(codeTemplates)) {
102
+ if (codeActions.has(action)) {
103
+ const regex = new RegExp(`{{${action}}}`, 'g');
104
+ const renderedTemplate = this.renderTemplate(template, repositoryInfo);
105
+ processed = processed.replace(regex, renderedTemplate);
106
+ }
107
+ }
108
+ }
109
+ return processed;
110
+ }
111
+ /**
112
+ * Render template with repository variable substitution
113
+ */
114
+ renderTemplate(template, repositoryInfo) {
115
+ if (!repositoryInfo)
116
+ return template;
117
+ return template.replace(/{{([^}]+)}}/g, (match, path) => {
118
+ const trimmedPath = path.trim();
119
+ // Handle proxy.repository.* variables
120
+ if (trimmedPath.startsWith('proxy.repository.')) {
121
+ const repoPath = trimmedPath.substring('proxy.repository.'.length);
122
+ let repoData = repositoryInfo.repository || repositoryInfo;
123
+ const value = (0, object_utils_1.getNestedValue)(repoData, repoPath);
124
+ return value !== undefined ? String(value) : match;
125
+ }
126
+ // Handle proxy.issueTracking.* variables (for split provider mode)
127
+ if (trimmedPath.startsWith('proxy.issueTracking.')) {
128
+ const issueTrackingPath = trimmedPath.substring('proxy.issueTracking.'.length);
129
+ const issueTrackingData = repositoryInfo.issueTracking;
130
+ if (issueTrackingData) {
131
+ const value = (0, object_utils_1.getNestedValue)(issueTrackingData, issueTrackingPath);
132
+ return value !== undefined ? String(value) : match;
133
+ }
134
+ return match;
135
+ }
136
+ return match;
137
+ });
138
+ }
139
+ /**
140
+ * Get available providers
141
+ */
142
+ getAvailableProviders() {
143
+ return Array.from(this.providers.keys());
144
+ }
145
+ /**
146
+ * Get available actions for a provider
147
+ */
148
+ getProviderActions(provider) {
149
+ const templates = this.providers.get(provider);
150
+ return templates ? Object.keys(templates) : [];
151
+ }
152
+ /**
153
+ * Validate that all template actions in content are supported
154
+ */
155
+ validateWorkflow(workflowContent, provider) {
156
+ const actionRegex = /{{(\w+)}}/g;
157
+ const actions = new Set();
158
+ let match;
159
+ while ((match = actionRegex.exec(workflowContent)) !== null) {
160
+ // Skip config variables
161
+ if (!match[1].startsWith('config.')) {
162
+ actions.add(match[1]);
163
+ }
164
+ }
165
+ const providerActions = this.getProviderActions(provider);
166
+ const missingActions = Array.from(actions).filter(action => !providerActions.includes(action));
167
+ return {
168
+ valid: missingActions.length === 0,
169
+ missingActions
170
+ };
171
+ }
172
+ }
173
+ exports.TemplateEngine = TemplateEngine;
174
+ // Global template engine instance
175
+ let globalTemplateEngine = null;
176
+ /**
177
+ * Get or create global template engine instance
178
+ */
179
+ function getTemplateEngine(registryPath) {
180
+ if (!globalTemplateEngine || (registryPath && registryPath !== globalTemplateEngine['registryPath'])) {
181
+ globalTemplateEngine = new TemplateEngine(registryPath);
182
+ }
183
+ return globalTemplateEngine;
184
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EMAIL_REGEX = void 0;
4
+ exports.validateEmail = validateEmail;
5
+ exports.getRequestMeta = getRequestMeta;
6
+ /**
7
+ * Standard email validation regex
8
+ */
9
+ exports.EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10
+ /**
11
+ * Validate an email address
12
+ */
13
+ function validateEmail(email) {
14
+ return exports.EMAIL_REGEX.test(email);
15
+ }
16
+ /**
17
+ * Extract IP and UserAgent from an Express Request
18
+ */
19
+ function getRequestMeta(req) {
20
+ const ip = req.headers['x-forwarded-for'] || req.ip || 'unknown';
21
+ const userAgent = req.headers['user-agent'] || 'unknown';
22
+ return { ip, userAgent };
23
+ }
@@ -413,6 +413,7 @@ class FraimLocalMCPServer {
413
413
  this.otlpServer = null;
414
414
  this.isShutdown = false;
415
415
  this.mentoringResponseCache = null;
416
+ this.jobStartCache = new Map();
416
417
  this.connectSyncInFlight = null;
417
418
  this.latestConnectSyncWarning = null;
418
419
  this.orgCacheRefreshInFlight = false;
@@ -1996,7 +1997,7 @@ class FraimLocalMCPServer {
1996
1997
  if (!this.mentoringResponseCache) {
1997
1998
  this.mentoringResponseCache = new Map();
1998
1999
  }
1999
- this.mentoringResponseCache.set(requestId, {
2000
+ this.mentoringResponseCache.set(request.id, {
2000
2001
  nextPhase: tutoringResponse.nextPhase,
2001
2002
  jobId: args.jobId || requestSessionId // Use jobId from args, fallback to sessionId
2002
2003
  });
@@ -2063,10 +2064,13 @@ class FraimLocalMCPServer {
2063
2064
  const overview = await mentor.getJobOverview(name);
2064
2065
  if (overview) {
2065
2066
  this.log(`✅ Local override found for get_fraim_job: ${name}`);
2067
+ const jobId = (0, crypto_1.randomUUID)();
2068
+ this.jobStartCache.set(request.id, { jobId, jobName: name });
2069
+ this.log(`📊 Generated jobId=${jobId} for job=${name}`);
2066
2070
  let responseText = overview.overview;
2067
2071
  if (!overview.isSimple) {
2068
2072
  responseText = `${mentor.getCompactPhaseAuthority()}\n\n${responseText}`;
2069
- responseText += `\n\n---\n\n**This job has phases.** Use \`seekMentoring\` to get phase-specific instructions.`;
2073
+ responseText += `\n\n---\n\n**Job ID:** \`${jobId}\`\n\n**This job has phases.** Use \`seekMentoring\` with the jobId above to get phase-specific instructions.`;
2070
2074
  }
2071
2075
  // Inject local learning context for job requests (RFC 177).
2072
2076
  const userEmail = this.ensureEngine().getUserEmail();
@@ -2356,7 +2360,7 @@ class FraimLocalMCPServer {
2356
2360
  if (!this.repoInfo) {
2357
2361
  this.detectRepoInfo();
2358
2362
  }
2359
- // Bug fix: Enrich seekMentoring args with response data
2363
+ // Enrich seekMentoring args with response data (nextPhase, jobId from cache)
2360
2364
  if (toolName === 'seekMentoring' && this.mentoringResponseCache) {
2361
2365
  const requestId = request.id;
2362
2366
  const cachedResponse = this.mentoringResponseCache.get(requestId);
@@ -2366,10 +2370,20 @@ class FraimLocalMCPServer {
2366
2370
  nextPhase: cachedResponse.nextPhase,
2367
2371
  jobId: cachedResponse.jobId
2368
2372
  };
2369
- this.mentoringResponseCache.delete(requestId); // Clean up
2373
+ this.mentoringResponseCache.delete(requestId);
2370
2374
  this.log(`📊 Enriched seekMentoring args with nextPhase=${cachedResponse.nextPhase}, jobId=${cachedResponse.jobId}`);
2371
2375
  }
2372
2376
  }
2377
+ // Enrich get_fraim_job args with the UUID jobId and action:start from jobStartCache
2378
+ if (toolName === 'get_fraim_job') {
2379
+ const requestId = request.id;
2380
+ const cached = this.jobStartCache.get(requestId);
2381
+ if (cached) {
2382
+ args = { ...args, jobId: cached.jobId, action: 'start' };
2383
+ this.jobStartCache.delete(requestId);
2384
+ this.log(`📊 Enriched get_fraim_job args with jobId=${cached.jobId}, action=start`);
2385
+ }
2386
+ }
2373
2387
  // Capture the current queue size before collection
2374
2388
  const beforeCount = this.usageCollector.getEventCount();
2375
2389
  try {
@@ -2410,6 +2424,16 @@ class FraimLocalMCPServer {
2410
2424
  this.log(`📊 🔍 Debug: get_fraim_file path="${args.path}"`);
2411
2425
  }
2412
2426
  }
2427
+ // Emit job-complete event when seekMentoring reaches the final phase
2428
+ if (toolName === 'seekMentoring' && args.nextPhase === null && args.jobName && args.jobId) {
2429
+ try {
2430
+ this.usageCollector.collectJobComplete(args.jobName, args.jobId, requestSessionId);
2431
+ this.log(`📊 ✅ Job complete event emitted: ${args.jobName} (jobId: ${args.jobId})`);
2432
+ }
2433
+ catch (err) {
2434
+ this.log(`📊 ⚠️ Job complete event failed (non-blocking): ${err.message}`);
2435
+ }
2436
+ }
2413
2437
  }
2414
2438
  catch (error) {
2415
2439
  this.log(`📊 ❌ Usage collection error: ${error.message}`);
@@ -142,6 +142,8 @@ class UsageCollector {
142
142
  case 'get_fraim_job':
143
143
  if (args.job)
144
144
  analyticsArgs.job = args.job;
145
+ if (args.action)
146
+ analyticsArgs.action = args.action;
145
147
  break;
146
148
  case 'fraim_connect':
147
149
  if (args.agent?.name)
@@ -179,6 +181,28 @@ class UsageCollector {
179
181
  return null;
180
182
  }
181
183
  }
184
+ /**
185
+ * Emit a job-completion event when seekMentoring returns nextPhase === null.
186
+ * Called by the local proxy after the final mentoring event is queued.
187
+ */
188
+ collectJobComplete(jobName, jobId, sessionId) {
189
+ const event = {
190
+ type: 'job',
191
+ name: jobName,
192
+ userId: this.userId || 'unknown',
193
+ sessionId,
194
+ success: true,
195
+ jobId,
196
+ repoIdentifier: this.repoIdentifier || undefined,
197
+ agentName: this.agentName || undefined,
198
+ agentModel: this.agentModel || undefined,
199
+ args: { action: 'complete', jobId },
200
+ };
201
+ this.events.push(event);
202
+ const msg = `[UsageCollector] ✅ Job complete event: ${jobName} (jobId: ${jobId}, queue: ${this.events.length})`;
203
+ console.error(msg);
204
+ process.stderr.write(msg + '\n');
205
+ }
182
206
  /**
183
207
  * Get collected events for upload and clear the queue
184
208
  */