hmn-masterclass-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hmn-masterclass.js +8 -0
- package/package.json +13 -0
- package/src/activity/logger.js +67 -0
- package/src/activity/sync.js +216 -0
- package/src/server.js +122 -0
- package/src/tools/content.js +221 -0
- package/src/tools/logging.js +122 -0
- package/src/tools/progress.js +170 -0
- package/src/tools/tracking.js +64 -0
- package/src/util/config.js +93 -0
- package/src/util/http-client.js +61 -0
- package/src/util/privacy.js +119 -0
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hmn-masterclass-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "HMN Masterclass MCP Server — adaptive learning for AI upskilling",
|
|
5
|
+
"bin": { "hmn-masterclass": "./bin/hmn-masterclass.js" },
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"scripts": { "start": "node bin/hmn-masterclass.js" },
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
10
|
+
"zod": "^3.25.76",
|
|
11
|
+
"axios": "^1.12.0"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity logger — appends sanitized JSON-line events to ~/.hmn/activity.jsonl.
|
|
3
|
+
* Thread-safe via appendFileSync (simplest approach in single-process MCP context).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { ensureConfigDir } = require('../util/config');
|
|
9
|
+
const { sanitizeEvent } = require('../util/privacy');
|
|
10
|
+
|
|
11
|
+
const ACTIVITY_FILE = 'activity.jsonl';
|
|
12
|
+
|
|
13
|
+
class ActivityLogger {
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.trackingEnabled = config.tracking_enabled !== false;
|
|
17
|
+
ensureConfigDir();
|
|
18
|
+
this.filePath = path.join(require('../util/config').getConfigPath(), ACTIVITY_FILE);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Log a single event to activity.jsonl.
|
|
23
|
+
* Applies privacy sanitization before writing.
|
|
24
|
+
* Silently skips if tracking is disabled (except for explicit user actions).
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} event - ActivityEvent object
|
|
27
|
+
* @param {Object} [options] - { force: boolean } — force logging even if tracking disabled
|
|
28
|
+
*/
|
|
29
|
+
logEvent(event, options = {}) {
|
|
30
|
+
// Allow force-logging for explicit user actions (log_build, submit_homework)
|
|
31
|
+
if (!this.trackingEnabled && !options.force) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const enriched = {
|
|
37
|
+
timestamp: new Date().toISOString(),
|
|
38
|
+
participant_email: this.config.email,
|
|
39
|
+
...event
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const sanitized = sanitizeEvent(enriched);
|
|
43
|
+
const line = JSON.stringify(sanitized) + '\n';
|
|
44
|
+
|
|
45
|
+
fs.appendFileSync(this.filePath, line, 'utf-8');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Never throw from logger — just write to stderr
|
|
48
|
+
process.stderr.write(`[hmn-mcp] Logger error: ${err.message}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set tracking enabled/disabled state.
|
|
54
|
+
*/
|
|
55
|
+
setTracking(enabled) {
|
|
56
|
+
this.trackingEnabled = enabled;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the path to the activity log file.
|
|
61
|
+
*/
|
|
62
|
+
getFilePath() {
|
|
63
|
+
return this.filePath;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { ActivityLogger };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background sync engine — reads events from activity.jsonl after sync_cursor,
|
|
3
|
+
* batches them, and POSTs to the platform API.
|
|
4
|
+
*
|
|
5
|
+
* Non-blocking: sync failures never affect MCP tool responses.
|
|
6
|
+
* Exponential backoff: 60s -> 120s -> 240s -> 600s max on failure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { getConfigPath } = require('../util/config');
|
|
12
|
+
|
|
13
|
+
const SYNC_CURSOR_FILE = 'sync_cursor.txt';
|
|
14
|
+
const ACTIVITY_FILE = 'activity.jsonl';
|
|
15
|
+
const MAX_BATCH_SIZE = 100;
|
|
16
|
+
const MAX_BACKOFF_MS = 600000; // 10 minutes
|
|
17
|
+
|
|
18
|
+
class SyncEngine {
|
|
19
|
+
constructor(config, httpClient, logger) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.http = httpClient;
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
this.intervalId = null;
|
|
24
|
+
this.currentIntervalMs = config.sync_interval_ms || 60000;
|
|
25
|
+
this.baseIntervalMs = this.currentIntervalMs;
|
|
26
|
+
this.backoffMultiplier = 1;
|
|
27
|
+
this.authError = false;
|
|
28
|
+
|
|
29
|
+
const configDir = getConfigPath();
|
|
30
|
+
this.cursorPath = path.join(configDir, SYNC_CURSOR_FILE);
|
|
31
|
+
this.activityPath = path.join(configDir, ACTIVITY_FILE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start the background sync interval.
|
|
36
|
+
*/
|
|
37
|
+
start() {
|
|
38
|
+
// Run an initial sync after a short delay to catch any pending events
|
|
39
|
+
setTimeout(() => this.sync(), 5000);
|
|
40
|
+
|
|
41
|
+
this.intervalId = setInterval(() => {
|
|
42
|
+
this.sync();
|
|
43
|
+
}, this.currentIntervalMs);
|
|
44
|
+
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stop the background sync.
|
|
50
|
+
*/
|
|
51
|
+
stop() {
|
|
52
|
+
if (this.intervalId) {
|
|
53
|
+
clearInterval(this.intervalId);
|
|
54
|
+
this.intervalId = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Trigger an immediate sync (used after log_build, submit_homework).
|
|
60
|
+
*/
|
|
61
|
+
async syncNow() {
|
|
62
|
+
return this.sync();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the current sync cursor (line number of last synced line).
|
|
67
|
+
*/
|
|
68
|
+
readCursor() {
|
|
69
|
+
try {
|
|
70
|
+
if (fs.existsSync(this.cursorPath)) {
|
|
71
|
+
const raw = fs.readFileSync(this.cursorPath, 'utf-8').trim();
|
|
72
|
+
const num = parseInt(raw, 10);
|
|
73
|
+
return isNaN(num) ? 0 : num;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore
|
|
77
|
+
}
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write the sync cursor.
|
|
83
|
+
*/
|
|
84
|
+
writeCursor(lineNumber) {
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(this.cursorPath, String(lineNumber), 'utf-8');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
process.stderr.write(`[hmn-mcp] Failed to write sync cursor: ${err.message}\n`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read unsynced lines from activity.jsonl.
|
|
94
|
+
*/
|
|
95
|
+
readUnsyncedEvents() {
|
|
96
|
+
try {
|
|
97
|
+
if (!fs.existsSync(this.activityPath)) {
|
|
98
|
+
return { events: [], totalLines: 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const content = fs.readFileSync(this.activityPath, 'utf-8');
|
|
102
|
+
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
|
103
|
+
const cursor = this.readCursor();
|
|
104
|
+
|
|
105
|
+
// Get lines after the cursor
|
|
106
|
+
const unsyncedLines = lines.slice(cursor);
|
|
107
|
+
const events = [];
|
|
108
|
+
|
|
109
|
+
for (const line of unsyncedLines.slice(0, MAX_BATCH_SIZE)) {
|
|
110
|
+
try {
|
|
111
|
+
events.push(JSON.parse(line));
|
|
112
|
+
} catch {
|
|
113
|
+
// Skip malformed lines
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { events, totalLines: lines.length, cursor };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
process.stderr.write(`[hmn-mcp] Failed to read activity log: ${err.message}\n`);
|
|
120
|
+
return { events: [], totalLines: 0, cursor: 0 };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Core sync method — reads unsynced events and POSTs them to the API.
|
|
126
|
+
*/
|
|
127
|
+
async sync() {
|
|
128
|
+
// Don't retry if we got a 401
|
|
129
|
+
if (this.authError) return;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const { events, totalLines, cursor } = this.readUnsyncedEvents();
|
|
133
|
+
|
|
134
|
+
if (events.length === 0) return;
|
|
135
|
+
|
|
136
|
+
await this.http.post('/api/masterclass/activity', {
|
|
137
|
+
participant_email: this.config.email,
|
|
138
|
+
events
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Success — advance cursor
|
|
142
|
+
const newCursor = cursor + events.length;
|
|
143
|
+
this.writeCursor(newCursor);
|
|
144
|
+
|
|
145
|
+
// Reset backoff on success
|
|
146
|
+
this.resetBackoff();
|
|
147
|
+
|
|
148
|
+
process.stderr.write(
|
|
149
|
+
`[hmn-mcp] Synced ${events.length} events (cursor: ${newCursor}/${totalLines})\n`
|
|
150
|
+
);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Check for auth errors — don't retry
|
|
153
|
+
if (err.message && err.message.includes('401')) {
|
|
154
|
+
this.authError = true;
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`[hmn-mcp] Auth error during sync — will not retry until next manual trigger.\n`
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Apply exponential backoff
|
|
162
|
+
this.applyBackoff();
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`[hmn-mcp] Sync failed (next retry in ${this.currentIntervalMs / 1000}s): ${err.message}\n`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply exponential backoff — doubles the interval up to MAX_BACKOFF_MS.
|
|
171
|
+
*/
|
|
172
|
+
applyBackoff() {
|
|
173
|
+
this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MS / this.baseIntervalMs);
|
|
174
|
+
this.currentIntervalMs = Math.min(this.baseIntervalMs * this.backoffMultiplier, MAX_BACKOFF_MS);
|
|
175
|
+
|
|
176
|
+
// Restart interval with new timing
|
|
177
|
+
if (this.intervalId) {
|
|
178
|
+
clearInterval(this.intervalId);
|
|
179
|
+
this.intervalId = setInterval(() => this.sync(), this.currentIntervalMs);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Reset backoff to base interval after a successful sync.
|
|
185
|
+
*/
|
|
186
|
+
resetBackoff() {
|
|
187
|
+
if (this.backoffMultiplier !== 1) {
|
|
188
|
+
this.backoffMultiplier = 1;
|
|
189
|
+
this.currentIntervalMs = this.baseIntervalMs;
|
|
190
|
+
|
|
191
|
+
// Restart interval with base timing
|
|
192
|
+
if (this.intervalId) {
|
|
193
|
+
clearInterval(this.intervalId);
|
|
194
|
+
this.intervalId = setInterval(() => this.sync(), this.currentIntervalMs);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reset auth error flag (called when user manually triggers sync).
|
|
201
|
+
*/
|
|
202
|
+
resetAuthError() {
|
|
203
|
+
this.authError = false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Factory: create and start the sync engine.
|
|
209
|
+
*/
|
|
210
|
+
function startSync(config, httpClient, logger) {
|
|
211
|
+
const engine = new SyncEngine(config, httpClient, logger);
|
|
212
|
+
engine.start();
|
|
213
|
+
return engine;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { SyncEngine, startSync };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmn/masterclass-mcp — MCP Server
|
|
3
|
+
*
|
|
4
|
+
* Adaptive learning server for HMN Masterclass participants.
|
|
5
|
+
* Exposes tools for progress tracking, personalized prompts, build logging,
|
|
6
|
+
* and homework submission. Passively logs activity and syncs to the platform.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
10
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
11
|
+
const { loadConfig } = require('./util/config');
|
|
12
|
+
const { createHttpClient } = require('./util/http-client');
|
|
13
|
+
const { ActivityLogger } = require('./activity/logger');
|
|
14
|
+
const { startSync } = require('./activity/sync');
|
|
15
|
+
const { registerProgressTools } = require('./tools/progress');
|
|
16
|
+
const { registerContentTools } = require('./tools/content');
|
|
17
|
+
const { registerLoggingTools } = require('./tools/logging');
|
|
18
|
+
const { registerTrackingTools } = require('./tools/tracking');
|
|
19
|
+
|
|
20
|
+
function createServer(config) {
|
|
21
|
+
const server = new McpServer({
|
|
22
|
+
name: 'hmn-masterclass',
|
|
23
|
+
version: '1.0.0'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Initialize HTTP client
|
|
27
|
+
const http = createHttpClient(config);
|
|
28
|
+
|
|
29
|
+
// Initialize activity logger
|
|
30
|
+
const logger = new ActivityLogger(config);
|
|
31
|
+
|
|
32
|
+
// Initialize sync engine (background interval)
|
|
33
|
+
const syncEngine = startSync(config, http, logger);
|
|
34
|
+
|
|
35
|
+
// Register all tool groups
|
|
36
|
+
registerProgressTools(server, http, logger);
|
|
37
|
+
registerContentTools(server, http, logger);
|
|
38
|
+
registerLoggingTools(server, http, logger, syncEngine);
|
|
39
|
+
registerTrackingTools(server, http, logger);
|
|
40
|
+
|
|
41
|
+
// Log session_start event
|
|
42
|
+
logger.logEvent({
|
|
43
|
+
event_type: 'session_start',
|
|
44
|
+
data: {
|
|
45
|
+
timestamp: new Date().toISOString()
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Handle process exit — log session_end and stop sync
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
logger.logEvent({
|
|
52
|
+
event_type: 'session_end',
|
|
53
|
+
data: {
|
|
54
|
+
timestamp: new Date().toISOString()
|
|
55
|
+
}
|
|
56
|
+
}, { force: true });
|
|
57
|
+
syncEngine.stop();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
process.on('SIGINT', () => {
|
|
61
|
+
cleanup();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
process.on('SIGTERM', () => {
|
|
66
|
+
cleanup();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
process.on('exit', () => {
|
|
71
|
+
// Best-effort — synchronous only at this point
|
|
72
|
+
try {
|
|
73
|
+
logger.logEvent({
|
|
74
|
+
event_type: 'session_end',
|
|
75
|
+
data: {
|
|
76
|
+
timestamp: new Date().toISOString()
|
|
77
|
+
}
|
|
78
|
+
}, { force: true });
|
|
79
|
+
} catch {
|
|
80
|
+
// Can't do anything here
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return server;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
|
|
90
|
+
// Validate required config
|
|
91
|
+
if (!config.email) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
'Error: HMN_EMAIL environment variable is required.\n' +
|
|
94
|
+
'Set it in your MCP server config:\n' +
|
|
95
|
+
' "env": { "HMN_EMAIL": "your.email@company.com" }\n'
|
|
96
|
+
);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!config.api_key) {
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
'Error: HMN_API_KEY environment variable is required.\n' +
|
|
103
|
+
'Generate one at https://behmn.com/dashboard → Settings → API Key\n' +
|
|
104
|
+
'Then set it in your MCP server config:\n' +
|
|
105
|
+
' "env": { "HMN_API_KEY": "hmn_ak_..." }\n'
|
|
106
|
+
);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`[hmn-mcp] Starting HMN Masterclass MCP server for ${config.email}\n` +
|
|
112
|
+
`[hmn-mcp] API host: ${config.api_host}\n` +
|
|
113
|
+
`[hmn-mcp] Tracking: ${config.tracking_enabled ? 'enabled' : 'disabled'}\n` +
|
|
114
|
+
`[hmn-mcp] Sync interval: ${config.sync_interval_ms / 1000}s\n`
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const server = createServer(config);
|
|
118
|
+
const transport = new StdioServerTransport();
|
|
119
|
+
await server.connect(transport);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { createServer, main };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content tools — hmn_get_prompt and hmn_my_profile.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { z } = require('zod');
|
|
6
|
+
|
|
7
|
+
function registerContentTools(server, http, logger) {
|
|
8
|
+
|
|
9
|
+
// ─── hmn_get_prompt ───────────────────────────────────────────────────
|
|
10
|
+
server.tool(
|
|
11
|
+
'hmn_get_prompt',
|
|
12
|
+
'Get personalized prompts for a specific module and section',
|
|
13
|
+
{
|
|
14
|
+
module_number: z.number().describe('Module number (e.g. 1, 2, 3)'),
|
|
15
|
+
section_id: z.string().describe('Section identifier (e.g. "competitive-intel", "client-brief")')
|
|
16
|
+
},
|
|
17
|
+
async ({ module_number, section_id }) => {
|
|
18
|
+
try {
|
|
19
|
+
const profile = await http.get('/api/masterclass/me');
|
|
20
|
+
const modules = profile.modules || [];
|
|
21
|
+
|
|
22
|
+
// Find the matching module
|
|
23
|
+
const mod = modules.find(
|
|
24
|
+
(m) => (m.module_number || m.number) === module_number
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (!mod) {
|
|
28
|
+
return {
|
|
29
|
+
content: [{
|
|
30
|
+
type: 'text',
|
|
31
|
+
text: `Module ${module_number} not found. Available modules: ${modules.map((m) => m.module_number || m.number).join(', ')}`
|
|
32
|
+
}]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Find the matching section
|
|
37
|
+
const section = (mod.sections || []).find(
|
|
38
|
+
(s) => s.id === section_id || s.title?.toLowerCase().includes(section_id.toLowerCase())
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!section) {
|
|
42
|
+
const sectionIds = (mod.sections || []).map((s) => s.id || s.title);
|
|
43
|
+
return {
|
|
44
|
+
content: [{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: `Section "${section_id}" not found in Module ${module_number}. Available sections: ${sectionIds.join(', ')}`
|
|
47
|
+
}]
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Get personalized content for this section
|
|
52
|
+
const personalizedContent = profile.personalizedContent || profile.personalized_content || {};
|
|
53
|
+
const moduleKey = `module_${module_number}`;
|
|
54
|
+
const sectionContent = personalizedContent[moduleKey]?.[section_id] ||
|
|
55
|
+
personalizedContent[section_id] ||
|
|
56
|
+
null;
|
|
57
|
+
|
|
58
|
+
let text = `## Module ${module_number}: ${mod.title}\n`;
|
|
59
|
+
text += `### ${section.title || section_id}\n\n`;
|
|
60
|
+
|
|
61
|
+
if (section.description) {
|
|
62
|
+
text += `${section.description}\n\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Format personalized examples
|
|
66
|
+
if (sectionContent) {
|
|
67
|
+
if (sectionContent.examples && sectionContent.examples.length > 0) {
|
|
68
|
+
text += `### Personalized Examples\n\n`;
|
|
69
|
+
for (const example of sectionContent.examples) {
|
|
70
|
+
text += `**${example.title || 'Example'}**\n`;
|
|
71
|
+
if (example.context) text += `_Context:_ ${example.context}\n`;
|
|
72
|
+
if (example.prompt) {
|
|
73
|
+
text += `\n\`\`\`\n${example.prompt}\n\`\`\`\n\n`;
|
|
74
|
+
}
|
|
75
|
+
if (example.expected_outcome) {
|
|
76
|
+
text += `_Expected outcome:_ ${example.expected_outcome}\n\n`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (sectionContent.homework) {
|
|
82
|
+
text += `### Homework\n\n`;
|
|
83
|
+
text += `${sectionContent.homework.description || sectionContent.homework}\n\n`;
|
|
84
|
+
|
|
85
|
+
if (sectionContent.homework.checklist) {
|
|
86
|
+
text += `**Checklist:**\n`;
|
|
87
|
+
for (const item of sectionContent.homework.checklist) {
|
|
88
|
+
text += `- [ ] ${item}\n`;
|
|
89
|
+
}
|
|
90
|
+
text += `\n`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (sectionContent.callouts && sectionContent.callouts.length > 0) {
|
|
95
|
+
text += `### Key Notes\n\n`;
|
|
96
|
+
for (const callout of sectionContent.callouts) {
|
|
97
|
+
text += `> ${callout}\n\n`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
text += `_No personalized content available for this section yet. `;
|
|
102
|
+
text += `Complete the section activities and your content will be generated based on your profile._\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { content: [{ type: 'text', text }] };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { content: [{ type: 'text', text: `Error fetching prompt: ${err.message}` }] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// ─── hmn_my_profile ───────────────────────────────────────────────────
|
|
113
|
+
server.tool(
|
|
114
|
+
'hmn_my_profile',
|
|
115
|
+
'View your full AI profile — maturity level, pain points, use cases, learning goals',
|
|
116
|
+
{},
|
|
117
|
+
async () => {
|
|
118
|
+
try {
|
|
119
|
+
const profile = await http.get('/api/masterclass/me');
|
|
120
|
+
|
|
121
|
+
const profileData = profile.profile_data || profile.profileData || profile;
|
|
122
|
+
const maturityLevel = profile.maturity_level || profile.maturityLevel || 0;
|
|
123
|
+
|
|
124
|
+
let text = `## Your AI Profile\n\n`;
|
|
125
|
+
|
|
126
|
+
// Basic info
|
|
127
|
+
text += `**Name:** ${profile.name || profile.full_name || 'N/A'}\n`;
|
|
128
|
+
text += `**Email:** ${profile.email || 'N/A'}\n`;
|
|
129
|
+
text += `**Organization:** ${profile.organization || profile.company || 'N/A'}\n`;
|
|
130
|
+
text += `**Role:** ${profile.role || profile.job_title || 'N/A'}\n\n`;
|
|
131
|
+
|
|
132
|
+
// Maturity level
|
|
133
|
+
text += `### AI Maturity Level\n`;
|
|
134
|
+
text += `**Level ${maturityLevel}** — ${getLevelLabel(maturityLevel)}\n\n`;
|
|
135
|
+
|
|
136
|
+
// Background
|
|
137
|
+
if (profileData.background || profileData.experience) {
|
|
138
|
+
text += `### Background\n`;
|
|
139
|
+
text += `${profileData.background || profileData.experience}\n\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Pain points
|
|
143
|
+
if (profileData.pain_points || profileData.painPoints) {
|
|
144
|
+
const pains = profileData.pain_points || profileData.painPoints;
|
|
145
|
+
text += `### Pain Points\n`;
|
|
146
|
+
if (Array.isArray(pains)) {
|
|
147
|
+
for (const pain of pains) {
|
|
148
|
+
text += `- ${pain}\n`;
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
text += `${pains}\n`;
|
|
152
|
+
}
|
|
153
|
+
text += `\n`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Use cases
|
|
157
|
+
if (profileData.use_cases || profileData.useCases) {
|
|
158
|
+
const cases = profileData.use_cases || profileData.useCases;
|
|
159
|
+
text += `### Use Cases\n`;
|
|
160
|
+
if (Array.isArray(cases)) {
|
|
161
|
+
for (const uc of cases) {
|
|
162
|
+
text += `- ${uc}\n`;
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
text += `${cases}\n`;
|
|
166
|
+
}
|
|
167
|
+
text += `\n`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Wants to learn
|
|
171
|
+
if (profileData.wants_to_learn || profileData.wantsToLearn || profileData.learning_goals) {
|
|
172
|
+
const goals = profileData.wants_to_learn || profileData.wantsToLearn || profileData.learning_goals;
|
|
173
|
+
text += `### Wants to Learn\n`;
|
|
174
|
+
if (Array.isArray(goals)) {
|
|
175
|
+
for (const goal of goals) {
|
|
176
|
+
text += `- ${goal}\n`;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
text += `${goals}\n`;
|
|
180
|
+
}
|
|
181
|
+
text += `\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tools already using
|
|
185
|
+
if (profileData.tools_using || profileData.toolsUsing || profileData.current_tools) {
|
|
186
|
+
const tools = profileData.tools_using || profileData.toolsUsing || profileData.current_tools;
|
|
187
|
+
text += `### Tools Currently Using\n`;
|
|
188
|
+
if (Array.isArray(tools)) {
|
|
189
|
+
for (const tool of tools) {
|
|
190
|
+
text += `- ${tool}\n`;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
text += `${tools}\n`;
|
|
194
|
+
}
|
|
195
|
+
text += `\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { content: [{ type: 'text', text }] };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return { content: [{ type: 'text', text: `Error fetching profile: ${err.message}` }] };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Map maturity level number to label.
|
|
208
|
+
*/
|
|
209
|
+
function getLevelLabel(level) {
|
|
210
|
+
const labels = {
|
|
211
|
+
0: 'Not Assessed',
|
|
212
|
+
1: 'AI Curious',
|
|
213
|
+
2: 'AI Experimenting',
|
|
214
|
+
3: 'AI Connecting',
|
|
215
|
+
4: 'AI Collaborating',
|
|
216
|
+
5: 'AI Leading'
|
|
217
|
+
};
|
|
218
|
+
return labels[level] || `Level ${level}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { registerContentTools };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging tools — hmn_log_build and hmn_submit_homework.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { z } = require('zod');
|
|
6
|
+
|
|
7
|
+
function registerLoggingTools(server, http, logger, syncEngine) {
|
|
8
|
+
|
|
9
|
+
// ─── hmn_log_build ────────────────────────────────────────────────────
|
|
10
|
+
server.tool(
|
|
11
|
+
'hmn_log_build',
|
|
12
|
+
'Log something you built — tell the system what you created',
|
|
13
|
+
{
|
|
14
|
+
description: z.string().describe('What you built — describe it in plain language'),
|
|
15
|
+
tools_used: z.array(z.string()).optional().describe('Tools you used (e.g. ["Firecrawl", "Claude", "Chrome DevTools"])')
|
|
16
|
+
},
|
|
17
|
+
async ({ description, tools_used }) => {
|
|
18
|
+
try {
|
|
19
|
+
// Write build_logged event to activity log
|
|
20
|
+
logger.logEvent({
|
|
21
|
+
event_type: 'build_logged',
|
|
22
|
+
data: {
|
|
23
|
+
description,
|
|
24
|
+
tools_used: tools_used || []
|
|
25
|
+
}
|
|
26
|
+
}, { force: true });
|
|
27
|
+
|
|
28
|
+
// Trigger immediate sync so the platform sees it right away
|
|
29
|
+
if (syncEngine) {
|
|
30
|
+
syncEngine.resetAuthError();
|
|
31
|
+
syncEngine.syncNow().catch(() => {
|
|
32
|
+
// Non-blocking — ignore sync errors
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let text = `## Build Logged\n\n`;
|
|
37
|
+
text += `**What you built:** ${description}\n`;
|
|
38
|
+
if (tools_used && tools_used.length > 0) {
|
|
39
|
+
text += `**Tools used:** ${tools_used.join(', ')}\n`;
|
|
40
|
+
}
|
|
41
|
+
text += `\nYour build has been recorded and will be synced to the platform. `;
|
|
42
|
+
text += `Builds like this help demonstrate your AI maturity growth.\n`;
|
|
43
|
+
|
|
44
|
+
return { content: [{ type: 'text', text }] };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { content: [{ type: 'text', text: `Error logging build: ${err.message}` }] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// ─── hmn_submit_homework ──────────────────────────────────────────────
|
|
52
|
+
server.tool(
|
|
53
|
+
'hmn_submit_homework',
|
|
54
|
+
'Submit your homework assignment',
|
|
55
|
+
{
|
|
56
|
+
module_number: z.number().describe('Module number (e.g. 1, 2, 3)'),
|
|
57
|
+
description: z.string().describe('Describe what you did for the homework'),
|
|
58
|
+
files_created: z.array(z.string()).optional().describe('File paths or names you created for this assignment')
|
|
59
|
+
},
|
|
60
|
+
async ({ module_number, description, files_created }) => {
|
|
61
|
+
try {
|
|
62
|
+
// POST to platform to mark progress
|
|
63
|
+
let progressResult = null;
|
|
64
|
+
try {
|
|
65
|
+
progressResult = await http.post('/api/masterclass/progress', {
|
|
66
|
+
module_number,
|
|
67
|
+
description,
|
|
68
|
+
files_created: files_created || []
|
|
69
|
+
});
|
|
70
|
+
} catch (apiErr) {
|
|
71
|
+
// Log the event even if the API call fails
|
|
72
|
+
process.stderr.write(
|
|
73
|
+
`[hmn-mcp] Progress API error (event still logged locally): ${apiErr.message}\n`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Write homework_submitted event to activity log
|
|
78
|
+
logger.logEvent({
|
|
79
|
+
event_type: 'homework_submitted',
|
|
80
|
+
data: {
|
|
81
|
+
module_number,
|
|
82
|
+
description,
|
|
83
|
+
file_names: files_created || [],
|
|
84
|
+
success: progressResult !== null
|
|
85
|
+
}
|
|
86
|
+
}, { force: true });
|
|
87
|
+
|
|
88
|
+
// Trigger immediate sync
|
|
89
|
+
if (syncEngine) {
|
|
90
|
+
syncEngine.resetAuthError();
|
|
91
|
+
syncEngine.syncNow().catch(() => {
|
|
92
|
+
// Non-blocking
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let text = `## Homework Submitted\n\n`;
|
|
97
|
+
text += `**Module ${module_number}**\n`;
|
|
98
|
+
text += `**Description:** ${description}\n`;
|
|
99
|
+
if (files_created && files_created.length > 0) {
|
|
100
|
+
text += `**Files:** ${files_created.join(', ')}\n`;
|
|
101
|
+
}
|
|
102
|
+
text += `\n`;
|
|
103
|
+
|
|
104
|
+
if (progressResult) {
|
|
105
|
+
text += `Your submission has been recorded on the platform.\n`;
|
|
106
|
+
if (progressResult.next_assignment) {
|
|
107
|
+
text += `\n### Next Assignment\n`;
|
|
108
|
+
text += `${progressResult.next_assignment}\n`;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
text += `Your submission has been logged locally and will sync when the platform is available.\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { content: [{ type: 'text', text }] };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { content: [{ type: 'text', text: `Error submitting homework: ${err.message}` }] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { registerLoggingTools };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress tools — hmn_check_progress and hmn_whats_next.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { z } = require('zod');
|
|
6
|
+
|
|
7
|
+
function registerProgressTools(server, http, logger) {
|
|
8
|
+
|
|
9
|
+
// ─── hmn_check_progress ───────────────────────────────────────────────
|
|
10
|
+
server.tool(
|
|
11
|
+
'hmn_check_progress',
|
|
12
|
+
'Check your masterclass progress — current level, completed sections, what\'s next',
|
|
13
|
+
{},
|
|
14
|
+
async () => {
|
|
15
|
+
try {
|
|
16
|
+
const profile = await http.get('/api/masterclass/me');
|
|
17
|
+
|
|
18
|
+
const maturityLevel = profile.maturity_level || profile.maturityLevel || 0;
|
|
19
|
+
const maturityLabel = getLevelLabel(maturityLevel);
|
|
20
|
+
const modules = profile.modules || [];
|
|
21
|
+
const totalSections = modules.reduce(
|
|
22
|
+
(acc, m) => acc + (m.sections?.length || 0), 0
|
|
23
|
+
);
|
|
24
|
+
const completedSections = modules.reduce(
|
|
25
|
+
(acc, m) => acc + (m.sections?.filter((s) => s.completed).length || 0), 0
|
|
26
|
+
);
|
|
27
|
+
const completionPct = totalSections > 0
|
|
28
|
+
? Math.round((completedSections / totalSections) * 100)
|
|
29
|
+
: 0;
|
|
30
|
+
|
|
31
|
+
// Find next uncompleted sections
|
|
32
|
+
const nextSections = [];
|
|
33
|
+
for (const mod of modules) {
|
|
34
|
+
for (const section of (mod.sections || [])) {
|
|
35
|
+
if (!section.completed && nextSections.length < 3) {
|
|
36
|
+
nextSections.push({
|
|
37
|
+
module: mod.module_number || mod.number,
|
|
38
|
+
title: mod.title,
|
|
39
|
+
section: section.id || section.title,
|
|
40
|
+
sectionTitle: section.title
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let text = `## Your Masterclass Progress\n\n`;
|
|
47
|
+
text += `**Maturity Level:** ${maturityLevel} — ${maturityLabel}\n`;
|
|
48
|
+
text += `**Completion:** ${completedSections}/${totalSections} sections (${completionPct}%)\n\n`;
|
|
49
|
+
|
|
50
|
+
if (nextSections.length > 0) {
|
|
51
|
+
text += `### Up Next\n`;
|
|
52
|
+
for (const ns of nextSections) {
|
|
53
|
+
text += `- **Module ${ns.module}** (${ns.title}): ${ns.sectionTitle}\n`;
|
|
54
|
+
}
|
|
55
|
+
} else if (completionPct === 100) {
|
|
56
|
+
text += `You've completed all sections! Check with your instructor about next steps.\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { content: [{ type: 'text', text }] };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { content: [{ type: 'text', text: `Error checking progress: ${err.message}` }] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// ─── hmn_whats_next ───────────────────────────────────────────────────
|
|
67
|
+
server.tool(
|
|
68
|
+
'hmn_whats_next',
|
|
69
|
+
'Get a personalized recommendation for what to work on next',
|
|
70
|
+
{},
|
|
71
|
+
async () => {
|
|
72
|
+
try {
|
|
73
|
+
const [profile, activitySummary] = await Promise.all([
|
|
74
|
+
http.get('/api/masterclass/me'),
|
|
75
|
+
http.get('/api/masterclass/activity/summary').catch(() => null)
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const modules = profile.modules || [];
|
|
79
|
+
const maturityLevel = profile.maturity_level || profile.maturityLevel || 0;
|
|
80
|
+
|
|
81
|
+
// Find incomplete sections
|
|
82
|
+
const incompleteSections = [];
|
|
83
|
+
for (const mod of modules) {
|
|
84
|
+
for (const section of (mod.sections || [])) {
|
|
85
|
+
if (!section.completed) {
|
|
86
|
+
incompleteSections.push({
|
|
87
|
+
module: mod.module_number || mod.number,
|
|
88
|
+
moduleTitle: mod.title,
|
|
89
|
+
sectionId: section.id || section.title,
|
|
90
|
+
sectionTitle: section.title
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Analyze activity patterns
|
|
97
|
+
const toolsUsed = activitySummary?.tools_used || [];
|
|
98
|
+
const toolsNeverUsed = activitySummary?.tools_never_used || [];
|
|
99
|
+
const totalBuilds = activitySummary?.total_builds || 0;
|
|
100
|
+
const homeworkStatus = activitySummary?.homework_submitted || [];
|
|
101
|
+
const pendingHomework = homeworkStatus.filter((h) => !h.submitted);
|
|
102
|
+
|
|
103
|
+
let text = `## What to Work on Next\n\n`;
|
|
104
|
+
|
|
105
|
+
// Priority 1: Pending homework
|
|
106
|
+
if (pendingHomework.length > 0) {
|
|
107
|
+
const hw = pendingHomework[0];
|
|
108
|
+
text += `### Priority: Submit Homework\n`;
|
|
109
|
+
text += `You have outstanding homework for **Module ${hw.module}**. `;
|
|
110
|
+
text += `Use \`hmn_submit_homework\` when you're ready to submit.\n\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Priority 2: Next incomplete section
|
|
114
|
+
if (incompleteSections.length > 0) {
|
|
115
|
+
const next = incompleteSections[0];
|
|
116
|
+
text += `### Next Section\n`;
|
|
117
|
+
text += `**Module ${next.module}** (${next.moduleTitle}): ${next.sectionTitle}\n`;
|
|
118
|
+
text += `Use \`hmn_get_prompt\` to get personalized prompts for this section.\n\n`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Priority 3: Try unused tools
|
|
122
|
+
if (toolsNeverUsed.length > 0) {
|
|
123
|
+
text += `### Try Something New\n`;
|
|
124
|
+
text += `You haven't used these tools yet: **${toolsNeverUsed.slice(0, 3).join(', ')}**. `;
|
|
125
|
+
text += `Experimenting with new tools is key to advancing your maturity level.\n\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Priority 4: Build more
|
|
129
|
+
if (totalBuilds < 3) {
|
|
130
|
+
text += `### Build Challenge\n`;
|
|
131
|
+
text += `You've logged ${totalBuilds} build${totalBuilds !== 1 ? 's' : ''} so far. `;
|
|
132
|
+
text += `Try building something with AI and log it with \`hmn_log_build\`.\n\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Maturity hint
|
|
136
|
+
if (activitySummary?.maturity_evidence?.suggested_level &&
|
|
137
|
+
activitySummary.maturity_evidence.suggested_level > maturityLevel) {
|
|
138
|
+
text += `### Level Up Potential\n`;
|
|
139
|
+
text += `Your activity suggests you might be ready for **Level ${activitySummary.maturity_evidence.suggested_level}**. `;
|
|
140
|
+
text += `Keep building — your instructor will review your progress.\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (incompleteSections.length === 0 && pendingHomework.length === 0) {
|
|
144
|
+
text += `You've completed all assigned work! Talk to your instructor about advanced challenges.\n`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { content: [{ type: 'text', text }] };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { content: [{ type: 'text', text: `Error generating recommendation: ${err.message}` }] };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Map maturity level number to label.
|
|
157
|
+
*/
|
|
158
|
+
function getLevelLabel(level) {
|
|
159
|
+
const labels = {
|
|
160
|
+
0: 'Not Assessed',
|
|
161
|
+
1: 'AI Curious',
|
|
162
|
+
2: 'AI Experimenting',
|
|
163
|
+
3: 'AI Connecting',
|
|
164
|
+
4: 'AI Collaborating',
|
|
165
|
+
5: 'AI Leading'
|
|
166
|
+
};
|
|
167
|
+
return labels[level] || `Level ${level}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { registerProgressTools };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracking control tools — hmn_pause_tracking and hmn_resume_tracking.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { z } = require('zod');
|
|
6
|
+
const { loadConfig, saveConfig } = require('../util/config');
|
|
7
|
+
|
|
8
|
+
function registerTrackingTools(server, http, logger) {
|
|
9
|
+
|
|
10
|
+
// ─── hmn_pause_tracking ───────────────────────────────────────────────
|
|
11
|
+
server.tool(
|
|
12
|
+
'hmn_pause_tracking',
|
|
13
|
+
'Pause passive activity tracking — your explicit actions (log_build, submit_homework) still work',
|
|
14
|
+
{},
|
|
15
|
+
async () => {
|
|
16
|
+
try {
|
|
17
|
+
saveConfig({ tracking_enabled: false });
|
|
18
|
+
logger.setTracking(false);
|
|
19
|
+
|
|
20
|
+
let text = `## Tracking Paused\n\n`;
|
|
21
|
+
text += `Passive activity tracking is now **disabled**.\n\n`;
|
|
22
|
+
text += `**What still works:**\n`;
|
|
23
|
+
text += `- \`hmn_log_build\` — explicitly log something you built\n`;
|
|
24
|
+
text += `- \`hmn_submit_homework\` — submit homework assignments\n`;
|
|
25
|
+
text += `- \`hmn_check_progress\` — check your progress\n`;
|
|
26
|
+
text += `- All other MCP tools\n\n`;
|
|
27
|
+
text += `**What's paused:**\n`;
|
|
28
|
+
text += `- Automatic logging of tasks, sessions, and tool usage\n\n`;
|
|
29
|
+
text += `Use \`hmn_resume_tracking\` to re-enable passive tracking.\n`;
|
|
30
|
+
|
|
31
|
+
return { content: [{ type: 'text', text }] };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { content: [{ type: 'text', text: `Error pausing tracking: ${err.message}` }] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// ─── hmn_resume_tracking ──────────────────────────────────────────────
|
|
39
|
+
server.tool(
|
|
40
|
+
'hmn_resume_tracking',
|
|
41
|
+
'Resume passive activity tracking',
|
|
42
|
+
{},
|
|
43
|
+
async () => {
|
|
44
|
+
try {
|
|
45
|
+
saveConfig({ tracking_enabled: true });
|
|
46
|
+
logger.setTracking(true);
|
|
47
|
+
|
|
48
|
+
let text = `## Tracking Resumed\n\n`;
|
|
49
|
+
text += `Passive activity tracking is now **enabled**.\n\n`;
|
|
50
|
+
text += `Your activity (tasks, tool usage, sessions) will be logged locally `;
|
|
51
|
+
text += `and synced to the platform to help personalize your learning experience.\n\n`;
|
|
52
|
+
text += `**Privacy:** Only metadata is tracked (tool names, task counts, session durations). `;
|
|
53
|
+
text += `File contents and full prompts are never logged. `;
|
|
54
|
+
text += `Prompts are truncated to 200 characters and secrets are redacted.\n`;
|
|
55
|
+
|
|
56
|
+
return { content: [{ type: 'text', text }] };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { content: [{ type: 'text', text: `Error resuming tracking: ${err.message}` }] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { registerTrackingTools };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management — reads from env vars + ~/.hmn/config.json.
|
|
3
|
+
* Env vars always take precedence over the config file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.hmn');
|
|
11
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure ~/.hmn/ directory exists.
|
|
15
|
+
*/
|
|
16
|
+
function ensureConfigDir() {
|
|
17
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
18
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the path to the ~/.hmn/ directory.
|
|
24
|
+
*/
|
|
25
|
+
function getConfigPath() {
|
|
26
|
+
ensureConfigDir();
|
|
27
|
+
return CONFIG_DIR;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load config from env vars + ~/.hmn/config.json.
|
|
32
|
+
* Env vars take precedence over file values.
|
|
33
|
+
*/
|
|
34
|
+
function loadConfig() {
|
|
35
|
+
ensureConfigDir();
|
|
36
|
+
|
|
37
|
+
// Read file config if it exists
|
|
38
|
+
let fileConfig = {};
|
|
39
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
42
|
+
fileConfig = JSON.parse(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
// Corrupted config file — ignore it
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Merge: env vars override file config
|
|
49
|
+
const config = {
|
|
50
|
+
email: process.env.HMN_EMAIL || fileConfig.email || '',
|
|
51
|
+
api_key: process.env.HMN_API_KEY || fileConfig.api_key || '',
|
|
52
|
+
api_host: process.env.HMN_API_HOST || fileConfig.api_host || 'https://behmn.com',
|
|
53
|
+
tracking_enabled:
|
|
54
|
+
fileConfig.tracking_enabled !== undefined
|
|
55
|
+
? fileConfig.tracking_enabled
|
|
56
|
+
: true,
|
|
57
|
+
sync_interval_ms:
|
|
58
|
+
fileConfig.sync_interval_ms !== undefined
|
|
59
|
+
? fileConfig.sync_interval_ms
|
|
60
|
+
: 60000
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write updates to ~/.hmn/config.json.
|
|
68
|
+
* Merges with existing config — does not overwrite unrelated keys.
|
|
69
|
+
*/
|
|
70
|
+
function saveConfig(updates) {
|
|
71
|
+
ensureConfigDir();
|
|
72
|
+
|
|
73
|
+
let existing = {};
|
|
74
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
75
|
+
try {
|
|
76
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
77
|
+
existing = JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
// Start fresh
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const merged = { ...existing, ...updates };
|
|
84
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
loadConfig,
|
|
90
|
+
saveConfig,
|
|
91
|
+
getConfigPath,
|
|
92
|
+
ensureConfigDir
|
|
93
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client — Axios instance with auth headers and error handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const axios = require('axios');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create an authenticated HTTP client for the HMN platform API.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} config - { api_key, api_host }
|
|
11
|
+
* @returns {import('axios').AxiosInstance} — response interceptor returns .data directly
|
|
12
|
+
*/
|
|
13
|
+
function createHttpClient(config) {
|
|
14
|
+
const client = axios.create({
|
|
15
|
+
baseURL: config.api_host,
|
|
16
|
+
headers: {
|
|
17
|
+
'Authorization': `Bearer ${config.api_key}`,
|
|
18
|
+
'X-HMN-API-Key': config.api_key,
|
|
19
|
+
'Content-Type': 'application/json'
|
|
20
|
+
},
|
|
21
|
+
timeout: 10000
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Unwrap response → return .data directly
|
|
25
|
+
client.interceptors.response.use(
|
|
26
|
+
(response) => response.data,
|
|
27
|
+
(error) => {
|
|
28
|
+
const status = error.response?.status;
|
|
29
|
+
const message =
|
|
30
|
+
error.response?.data?.message ||
|
|
31
|
+
error.response?.data?.error ||
|
|
32
|
+
error.message;
|
|
33
|
+
|
|
34
|
+
if (status === 401) {
|
|
35
|
+
return Promise.reject(
|
|
36
|
+
new Error(
|
|
37
|
+
`HMN API auth error (401): Invalid or expired API key. ` +
|
|
38
|
+
`Regenerate at ${config.api_host}/dashboard → Settings → API Key.`
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (status === 404) {
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new Error(
|
|
46
|
+
`HMN API not found (404): ${message}. ` +
|
|
47
|
+
`Check that your HMN_API_HOST is correct (current: ${config.api_host}).`
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Promise.reject(
|
|
53
|
+
new Error(`HMN API error (${status || 'network'}): ${message}`)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return client;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { createHttpClient };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy utilities — truncation, path stripping, secret redaction.
|
|
3
|
+
* All sanitization happens before any event is written to disk or sent to the API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const SECRET_PATTERNS = [
|
|
9
|
+
// Generic API keys / tokens
|
|
10
|
+
/(?:api[_-]?key|apikey|token|secret|password|passwd|authorization|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
|
|
11
|
+
// AWS
|
|
12
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
13
|
+
// GitHub
|
|
14
|
+
/gh[pso]_[A-Za-z0-9_]{36,}/g,
|
|
15
|
+
// Slack
|
|
16
|
+
/xox[bporas]-[A-Za-z0-9-]+/g,
|
|
17
|
+
// Generic long hex/base64 strings that look like secrets (32+ chars)
|
|
18
|
+
/(?:sk|pk|key|token|secret)[_-][A-Za-z0-9]{32,}/gi,
|
|
19
|
+
// HMN API keys
|
|
20
|
+
/hmn_ak_[A-Za-z0-9_\-]{16,}/g,
|
|
21
|
+
// Bearer tokens in text
|
|
22
|
+
/Bearer\s+[A-Za-z0-9_\-./+=]{20,}/gi,
|
|
23
|
+
// Connection strings
|
|
24
|
+
/(?:postgres|mysql|mongodb|redis):\/\/[^\s]+/gi
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Truncate prompt text to 200 characters.
|
|
29
|
+
*/
|
|
30
|
+
function sanitizePrompt(text) {
|
|
31
|
+
if (!text || typeof text !== 'string') return '';
|
|
32
|
+
return text.length > 200 ? text.slice(0, 200) + '...' : text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract only the last folder name from a full path.
|
|
37
|
+
* /Users/matty/Documents/my-project/src/index.js → my-project
|
|
38
|
+
*/
|
|
39
|
+
function sanitizePath(fullPath) {
|
|
40
|
+
if (!fullPath || typeof fullPath !== 'string') return '';
|
|
41
|
+
// Normalize and split
|
|
42
|
+
const normalized = path.normalize(fullPath);
|
|
43
|
+
const parts = normalized.split(path.sep).filter(Boolean);
|
|
44
|
+
// If it looks like a file path, return the parent folder name
|
|
45
|
+
if (parts.length === 0) return '';
|
|
46
|
+
// Check if last segment has an extension (it's a file)
|
|
47
|
+
const last = parts[parts.length - 1];
|
|
48
|
+
if (last.includes('.') && parts.length > 1) {
|
|
49
|
+
return parts[parts.length - 2] + '/';
|
|
50
|
+
}
|
|
51
|
+
return last + '/';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strip patterns matching API keys, tokens, passwords from text.
|
|
56
|
+
*/
|
|
57
|
+
function redactSecrets(text) {
|
|
58
|
+
if (!text || typeof text !== 'string') return '';
|
|
59
|
+
let cleaned = text;
|
|
60
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
61
|
+
// Reset lastIndex for global patterns
|
|
62
|
+
pattern.lastIndex = 0;
|
|
63
|
+
cleaned = cleaned.replace(pattern, '[REDACTED]');
|
|
64
|
+
}
|
|
65
|
+
return cleaned;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Apply all sanitization to an ActivityEvent before logging.
|
|
70
|
+
*/
|
|
71
|
+
function sanitizeEvent(event) {
|
|
72
|
+
if (!event) return event;
|
|
73
|
+
|
|
74
|
+
const sanitized = { ...event };
|
|
75
|
+
|
|
76
|
+
if (sanitized.data) {
|
|
77
|
+
sanitized.data = { ...sanitized.data };
|
|
78
|
+
|
|
79
|
+
// Truncate prompt summaries
|
|
80
|
+
if (sanitized.data.prompt_summary) {
|
|
81
|
+
sanitized.data.prompt_summary = redactSecrets(
|
|
82
|
+
sanitizePrompt(sanitized.data.prompt_summary)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Strip file paths to folder names
|
|
87
|
+
if (sanitized.data.project_dir) {
|
|
88
|
+
sanitized.data.project_dir = sanitizePath(sanitized.data.project_dir);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Sanitize file names — keep names but redact any secret-looking names
|
|
92
|
+
if (Array.isArray(sanitized.data.file_names)) {
|
|
93
|
+
sanitized.data.file_names = sanitized.data.file_names.map((f) =>
|
|
94
|
+
redactSecrets(path.basename(f))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Redact secrets from description
|
|
99
|
+
if (sanitized.data.description) {
|
|
100
|
+
sanitized.data.description = redactSecrets(sanitized.data.description);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Redact secrets from tool names (unlikely but safe)
|
|
104
|
+
if (Array.isArray(sanitized.data.tools_used)) {
|
|
105
|
+
sanitized.data.tools_used = sanitized.data.tools_used.map((t) =>
|
|
106
|
+
redactSecrets(t)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return sanitized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
sanitizePrompt,
|
|
116
|
+
sanitizePath,
|
|
117
|
+
redactSecrets,
|
|
118
|
+
sanitizeEvent
|
|
119
|
+
};
|