fraim-framework 2.0.104 → 2.0.106
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/dist/src/cli/commands/add-ide.js +5 -0
- package/dist/src/cli/commands/add-provider.js +23 -0
- package/dist/src/cli/commands/init-project.js +6 -0
- package/dist/src/cli/commands/setup.js +20 -0
- package/dist/src/cli/commands/sync.js +15 -0
- package/dist/src/cli/setup/claude-code-telemetry.js +59 -0
- package/dist/src/cli/setup/ide-global-integration.js +120 -0
- package/dist/src/cli/setup/user-level-sync.js +139 -0
- package/dist/src/cli/utils/remote-sync.js +17 -8
- package/dist/src/core/utils/local-registry-resolver.js +26 -14
- package/dist/src/core/utils/project-fraim-paths.js +37 -0
- package/dist/src/local-mcp-server/otlp-metrics-receiver.js +262 -0
- package/dist/src/local-mcp-server/prometheus-scraper.js +152 -0
- package/dist/src/local-mcp-server/stdio-server.js +40 -5
- package/dist/src/local-mcp-server/usage-collector.js +9 -0
- package/package.json +1 -1
|
@@ -45,6 +45,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
45
45
|
const ide_detector_1 = require("../setup/ide-detector");
|
|
46
46
|
const mcp_config_generator_1 = require("../setup/mcp-config-generator");
|
|
47
47
|
const codex_local_config_1 = require("../setup/codex-local-config");
|
|
48
|
+
const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
|
|
48
49
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
49
50
|
const mcp_server_registry_1 = require("../mcp/mcp-server-registry");
|
|
50
51
|
const get_provider_client_1 = require("../api/get-provider-client");
|
|
@@ -249,6 +250,10 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
|
|
|
249
250
|
const status = localResult.created ? 'Created' : localResult.updated ? 'Updated' : 'Verified';
|
|
250
251
|
console.log(chalk_1.default.green(` ✅ ${status} local ${ide.name} config: ${localResult.path}`));
|
|
251
252
|
}
|
|
253
|
+
// Enable token telemetry for Claude Code via project-level settings
|
|
254
|
+
if (ide.configType === 'claude-code') {
|
|
255
|
+
(0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
|
|
256
|
+
}
|
|
252
257
|
};
|
|
253
258
|
const listSupportedIDEs = () => {
|
|
254
259
|
const allIDEs = (0, ide_detector_1.getAllSupportedIDEs)();
|
|
@@ -9,6 +9,7 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
9
9
|
const prompts_1 = __importDefault(require("prompts"));
|
|
10
10
|
const fs_1 = __importDefault(require("fs"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const child_process_1 = require("child_process");
|
|
12
13
|
const mcp_config_generator_1 = require("../setup/mcp-config-generator");
|
|
13
14
|
const ide_detector_1 = require("../setup/ide-detector");
|
|
14
15
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
@@ -163,6 +164,28 @@ const runAddProvider = async (provider, options) => {
|
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
}
|
|
167
|
+
// Check prerequisites for the provider's MCP server command
|
|
168
|
+
const providerDefCheck = await (0, provider_registry_1.getProvider)(provider);
|
|
169
|
+
if (providerDefCheck?.mcpServer?.type === 'stdio' && providerDefCheck.mcpServer.command && !process.env.FRAIM_SKIP_PREREQ_CHECK) {
|
|
170
|
+
const cmd = providerDefCheck.mcpServer.command;
|
|
171
|
+
// Only check non-standard commands (npx/node are assumed to be available)
|
|
172
|
+
if (!['npx', 'node', 'npm'].includes(cmd)) {
|
|
173
|
+
try {
|
|
174
|
+
(0, child_process_1.execSync)(`${cmd} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
const installInstructions = {
|
|
178
|
+
uvx: 'Install uv first: https://docs.astral.sh/uv/getting-started/installation/\n' +
|
|
179
|
+
' macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n' +
|
|
180
|
+
' Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"',
|
|
181
|
+
};
|
|
182
|
+
console.log(chalk_1.default.red(`\n❌ "${cmd}" is required for ${providerName} but was not found on your system.\n`));
|
|
183
|
+
console.log(chalk_1.default.yellow(installInstructions[cmd] || `Please install "${cmd}" and try again.`));
|
|
184
|
+
console.log(chalk_1.default.gray('\nAfter installing, restart your terminal and run this command again.'));
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
166
189
|
// Get credentials using generic prompt system
|
|
167
190
|
try {
|
|
168
191
|
// Build provided tokens/configs from CLI options
|
|
@@ -16,6 +16,7 @@ const platform_detection_1 = require("../utils/platform-detection");
|
|
|
16
16
|
const version_utils_1 = require("../utils/version-utils");
|
|
17
17
|
const ide_detector_1 = require("../setup/ide-detector");
|
|
18
18
|
const codex_local_config_1 = require("../setup/codex-local-config");
|
|
19
|
+
const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
|
|
19
20
|
const provider_registry_1 = require("../providers/provider-registry");
|
|
20
21
|
const fraim_gitignore_1 = require("../utils/fraim-gitignore");
|
|
21
22
|
const config_writer_1 = require("../../core/config-writer");
|
|
@@ -323,6 +324,11 @@ const runInitProject = async () => {
|
|
|
323
324
|
const status = codexLocalResult.created ? 'Created' : codexLocalResult.updated ? 'Updated' : 'Verified';
|
|
324
325
|
console.log(chalk_1.default.green(`${status} project Codex config at ${codexLocalResult.path}`));
|
|
325
326
|
}
|
|
327
|
+
// Enable token telemetry for Claude Code (user-level, applies to all projects)
|
|
328
|
+
const claudeCodeAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'claude-code');
|
|
329
|
+
if (claudeCodeAvailable) {
|
|
330
|
+
(0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
|
|
331
|
+
}
|
|
326
332
|
const adapterUpdates = (0, agent_adapters_1.ensureAgentAdapterFiles)(projectRoot);
|
|
327
333
|
if (adapterUpdates.length > 0) {
|
|
328
334
|
console.log(chalk_1.default.green(`Updated FRAIM agent adapter files: ${adapterUpdates.join(', ')}`));
|
|
@@ -602,6 +602,26 @@ const runSetup = async (options) => {
|
|
|
602
602
|
console.log(chalk_1.default.gray(' You can update them manually with: fraim add-ide <ide-name>\n'));
|
|
603
603
|
}
|
|
604
604
|
}
|
|
605
|
+
// Sync user-level FRAIM artifacts (always, on both initial and update)
|
|
606
|
+
try {
|
|
607
|
+
const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
|
|
608
|
+
console.log(chalk_1.default.blue('\n📦 Syncing user-level FRAIM content...'));
|
|
609
|
+
await syncUserLevelArtifacts();
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
console.log(chalk_1.default.yellow(`⚠️ User-level content sync encountered issues: ${e.message}`));
|
|
613
|
+
console.log(chalk_1.default.gray(' You can sync later with: fraim sync --global'));
|
|
614
|
+
}
|
|
615
|
+
// Install IDE slash commands and global rules
|
|
616
|
+
try {
|
|
617
|
+
const { installSlashCommands, installGlobalRules } = await Promise.resolve().then(() => __importStar(require('../setup/ide-global-integration')));
|
|
618
|
+
console.log(chalk_1.default.blue('\n🔗 Installing IDE integrations...'));
|
|
619
|
+
await installSlashCommands();
|
|
620
|
+
await installGlobalRules();
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
console.log(chalk_1.default.yellow(`⚠️ IDE integration encountered issues: ${e.message}`));
|
|
624
|
+
}
|
|
605
625
|
// Auto-run project init if we're in a git repo (only on initial setup)
|
|
606
626
|
if (!isUpdate) {
|
|
607
627
|
if ((0, platform_detection_1.isGitRepository)()) {
|
|
@@ -84,6 +84,20 @@ function updateVersionInConfig(fraimDir) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
const runSync = async (options) => {
|
|
87
|
+
// Handle --global flag: sync to user-level ~/.fraim/ instead of project
|
|
88
|
+
if (options.global) {
|
|
89
|
+
console.log(chalk_1.default.blue('Syncing FRAIM content to user-level directory (~/.fraim/)...'));
|
|
90
|
+
try {
|
|
91
|
+
const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
|
|
92
|
+
await syncUserLevelArtifacts();
|
|
93
|
+
console.log(chalk_1.default.green('\n✅ User-level FRAIM content sync complete.'));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error(chalk_1.default.red(`User-level sync failed: ${error.message}`));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
87
101
|
const projectRoot = process.cwd();
|
|
88
102
|
const config = (0, config_loader_1.loadFraimConfig)();
|
|
89
103
|
const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
|
|
@@ -171,4 +185,5 @@ exports.syncCommand = new commander_1.Command('sync')
|
|
|
171
185
|
.option('-f, --force', 'Force sync even if digest matches')
|
|
172
186
|
.option('--skip-updates', 'Skip checking for CLI updates (legacy)')
|
|
173
187
|
.option('--local', 'Sync from local development server (port derived from git branch)')
|
|
188
|
+
.option('--global', 'Sync user-level FRAIM content (~/.fraim/) instead of project')
|
|
174
189
|
.action(exports.runSync);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Configures Claude Code user-level settings for FRAIM token telemetry.
|
|
4
|
+
*
|
|
5
|
+
* Writes OTel env vars to ~/.claude/settings.json so ALL Claude Code
|
|
6
|
+
* sessions push metrics to the FRAIM local proxy's OTLP receiver.
|
|
7
|
+
* User-level so it works across all projects without per-project setup.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.ensureClaudeCodeTelemetryEnv = ensureClaudeCodeTelemetryEnv;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
17
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
18
|
+
const TELEMETRY_ENV = {
|
|
19
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: '1',
|
|
20
|
+
OTEL_METRICS_EXPORTER: 'otlp',
|
|
21
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json',
|
|
22
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Ensure Claude Code user-level settings include OTel env vars for token telemetry.
|
|
26
|
+
* Writes to ~/.claude/settings.json. Merges without overwriting user-set values.
|
|
27
|
+
*/
|
|
28
|
+
function ensureClaudeCodeTelemetryEnv() {
|
|
29
|
+
const settingsDir = path_1.default.join(os_1.default.homedir(), '.claude');
|
|
30
|
+
const settingsPath = path_1.default.join(settingsDir, 'settings.json');
|
|
31
|
+
let settings = {};
|
|
32
|
+
if (fs_1.default.existsSync(settingsPath)) {
|
|
33
|
+
try {
|
|
34
|
+
settings = JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Corrupt file — will overwrite
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (!fs_1.default.existsSync(settingsDir)) {
|
|
41
|
+
fs_1.default.mkdirSync(settingsDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const existingEnv = settings.env || {};
|
|
44
|
+
let added = 0;
|
|
45
|
+
for (const [key, value] of Object.entries(TELEMETRY_ENV)) {
|
|
46
|
+
if (!(key in existingEnv)) {
|
|
47
|
+
existingEnv[key] = value;
|
|
48
|
+
added++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (added > 0) {
|
|
52
|
+
settings.env = existingEnv;
|
|
53
|
+
fs_1.default.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
54
|
+
console.log(chalk_1.default.green(` ✅ Enabled token telemetry in ~/.claude/settings.json (${added} env vars)`));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(chalk_1.default.gray(` ⏭️ Token telemetry already configured in ~/.claude/settings.json`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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.installSlashCommands = installSlashCommands;
|
|
7
|
+
exports.installGlobalRules = installGlobalRules;
|
|
8
|
+
/**
|
|
9
|
+
* IDE Global Integration
|
|
10
|
+
*
|
|
11
|
+
* Installs FRAIM slash commands and global rules into user-level IDE
|
|
12
|
+
* configuration directories. These enable FRAIM to be discoverable
|
|
13
|
+
* in any project without requiring fraim init-project.
|
|
14
|
+
*
|
|
15
|
+
* Part of: User-Level FRAIM Artifacts with Local Shadow Semantics
|
|
16
|
+
*/
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const os_1 = __importDefault(require("os"));
|
|
20
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
21
|
+
const FRAIM_SLASH_COMMAND_CONTENT = `The user wants to run FRAIM. The requested job or topic is: $ARGUMENTS
|
|
22
|
+
|
|
23
|
+
Follow this process:
|
|
24
|
+
|
|
25
|
+
1. **If no argument was given** (the line above ends with ": "): call \`list_fraim_jobs()\` to discover available jobs. List each by name and its Intent line. Ask the user which job they want to run, then proceed to step 2.
|
|
26
|
+
|
|
27
|
+
2. **Find the match**: from the list returned by \`list_fraim_jobs()\`, find the job whose name matches or closely resembles the argument. If no job matches, search for a matching skill by calling \`get_fraim_file({ path: "skills/<likely-category>/<argument>.md" })\` — try common categories like \`engineering/\`, \`marketing/\`, \`business/\`, \`product-management/\`, \`ai-tools/\`. Confirm the match with the user.
|
|
28
|
+
|
|
29
|
+
3. **Load the full content**:
|
|
30
|
+
- For jobs: call \`get_fraim_job({ job: "<matched-job-name>" })\` — never execute from stub content.
|
|
31
|
+
- For skills: the content from \`get_fraim_file\` is the full skill. Use it directly.
|
|
32
|
+
|
|
33
|
+
4. **Execute**: for jobs, follow the phased instructions returned by \`get_fraim_job\`, using \`seekMentoring\` at phase transitions where indicated. For skills, apply the skill steps to the user's current context.
|
|
34
|
+
`;
|
|
35
|
+
const FRAIM_CURSOR_RULES_CONTENT = `# FRAIM Rules
|
|
36
|
+
|
|
37
|
+
This workspace uses FRAIM (Framework for Rigor-based AI Management).
|
|
38
|
+
|
|
39
|
+
## Discovery
|
|
40
|
+
- Before acting on any user request, check if a FRAIM job matches the intent.
|
|
41
|
+
- Call \`list_fraim_jobs()\` to discover available jobs.
|
|
42
|
+
- Call \`get_fraim_job({ job: "<job-name>" })\` to get full phased instructions.
|
|
43
|
+
|
|
44
|
+
## Execution
|
|
45
|
+
- Jobs are FRAIM's primary execution units — structured, multi-phase workflows.
|
|
46
|
+
- Skills are reusable capabilities that jobs compose.
|
|
47
|
+
- Rules are always-on constraints and conventions.
|
|
48
|
+
- Follow phased instructions and use \`seekMentoring\` at phase transitions.
|
|
49
|
+
|
|
50
|
+
## Principles
|
|
51
|
+
- Never execute from job stubs — always load full instructions via MCP.
|
|
52
|
+
- Follow the constitution: integrity, correctness, contribution, completeness.
|
|
53
|
+
`;
|
|
54
|
+
/**
|
|
55
|
+
* Install the FRAIM slash command for Claude Code at the user level.
|
|
56
|
+
* Writes to ~/.claude/commands/fraim.md.
|
|
57
|
+
* Does NOT overwrite if the file already exists.
|
|
58
|
+
*
|
|
59
|
+
* @param homeDir - Override for home directory (for testing)
|
|
60
|
+
*/
|
|
61
|
+
async function installSlashCommands(homeDir) {
|
|
62
|
+
const home = homeDir || os_1.default.homedir();
|
|
63
|
+
const claudeDir = path_1.default.join(home, '.claude');
|
|
64
|
+
// Only install if Claude Code is installed (indicated by .claude/ existing)
|
|
65
|
+
if (!fs_1.default.existsSync(claudeDir)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const commandsDir = path_1.default.join(claudeDir, 'commands');
|
|
69
|
+
const slashCommandPath = path_1.default.join(commandsDir, 'fraim.md');
|
|
70
|
+
// Do not overwrite existing file
|
|
71
|
+
if (fs_1.default.existsSync(slashCommandPath)) {
|
|
72
|
+
console.log(chalk_1.default.gray(' Claude slash command already exists — skipping'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
fs_1.default.mkdirSync(commandsDir, { recursive: true });
|
|
76
|
+
fs_1.default.writeFileSync(slashCommandPath, FRAIM_SLASH_COMMAND_CONTENT, 'utf8');
|
|
77
|
+
console.log(chalk_1.default.green(' ✅ Installed Claude slash command (~/.claude/commands/fraim.md)'));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Install FRAIM global rules/instructions for supported IDEs.
|
|
81
|
+
* Supports: Cursor, Codex, Windsurf, Kiro
|
|
82
|
+
* Does NOT overwrite if files already exist.
|
|
83
|
+
*
|
|
84
|
+
* @param homeDir - Override for home directory (for testing)
|
|
85
|
+
*/
|
|
86
|
+
async function installGlobalRules(homeDir) {
|
|
87
|
+
const home = homeDir || os_1.default.homedir();
|
|
88
|
+
// Cursor: ~/.cursor/rules/fraim-rules.md
|
|
89
|
+
const cursorDir = path_1.default.join(home, '.cursor');
|
|
90
|
+
if (fs_1.default.existsSync(cursorDir)) {
|
|
91
|
+
installRuleFile(path_1.default.join(cursorDir, 'rules', 'fraim-rules.md'), FRAIM_CURSOR_RULES_CONTENT, 'Cursor global rules (~/.cursor/rules/fraim-rules.md)');
|
|
92
|
+
}
|
|
93
|
+
// Codex: ~/.codex/instructions.md
|
|
94
|
+
const codexDir = path_1.default.join(home, '.codex');
|
|
95
|
+
if (fs_1.default.existsSync(codexDir)) {
|
|
96
|
+
installRuleFile(path_1.default.join(codexDir, 'instructions.md'), FRAIM_CURSOR_RULES_CONTENT, 'Codex global instructions (~/.codex/instructions.md)');
|
|
97
|
+
}
|
|
98
|
+
// Windsurf: ~/.codeium/windsurf/rules/fraim-rules.md
|
|
99
|
+
const windsurfDir = path_1.default.join(home, '.codeium', 'windsurf');
|
|
100
|
+
if (fs_1.default.existsSync(windsurfDir)) {
|
|
101
|
+
installRuleFile(path_1.default.join(windsurfDir, 'rules', 'fraim-rules.md'), FRAIM_CURSOR_RULES_CONTENT, 'Windsurf global rules (~/.codeium/windsurf/rules/fraim-rules.md)');
|
|
102
|
+
}
|
|
103
|
+
// Kiro: ~/.kiro/rules/fraim-rules.md
|
|
104
|
+
const kiroDir = path_1.default.join(home, '.kiro');
|
|
105
|
+
if (fs_1.default.existsSync(kiroDir)) {
|
|
106
|
+
installRuleFile(path_1.default.join(kiroDir, 'rules', 'fraim-rules.md'), FRAIM_CURSOR_RULES_CONTENT, 'Kiro global rules (~/.kiro/rules/fraim-rules.md)');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Install a rule file if it doesn't already exist.
|
|
111
|
+
*/
|
|
112
|
+
function installRuleFile(filePath, content, displayName) {
|
|
113
|
+
if (fs_1.default.existsSync(filePath)) {
|
|
114
|
+
console.log(chalk_1.default.gray(` ${displayName.split('(')[0].trim()} already exist — skipping`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
|
|
118
|
+
fs_1.default.writeFileSync(filePath, content, 'utf8');
|
|
119
|
+
console.log(chalk_1.default.green(` ✅ Installed ${displayName}`));
|
|
120
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.ensureUserLevelDirectories = ensureUserLevelDirectories;
|
|
40
|
+
exports.syncUserLevelArtifacts = syncUserLevelArtifacts;
|
|
41
|
+
/**
|
|
42
|
+
* User-Level FRAIM Artifact Sync
|
|
43
|
+
*
|
|
44
|
+
* Syncs jobs, skills, rules, and docs stubs from the remote FRAIM server
|
|
45
|
+
* to the user-level ~/.fraim/ directory. This makes FRAIM discoverable
|
|
46
|
+
* everywhere without requiring fraim init-project.
|
|
47
|
+
*
|
|
48
|
+
* Part of: User-Level FRAIM Artifacts with Local Shadow Semantics
|
|
49
|
+
*/
|
|
50
|
+
const fs_1 = __importDefault(require("fs"));
|
|
51
|
+
const path_1 = __importDefault(require("path"));
|
|
52
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
53
|
+
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
54
|
+
/**
|
|
55
|
+
* Ensure the user-level FRAIM directory structure exists for content sync.
|
|
56
|
+
* Creates directories for synced content and personalized overrides.
|
|
57
|
+
*/
|
|
58
|
+
function ensureUserLevelDirectories(userFraimDir) {
|
|
59
|
+
const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
|
|
60
|
+
const dirs = [
|
|
61
|
+
path_1.default.join(baseDir, 'ai-employee', 'jobs'),
|
|
62
|
+
path_1.default.join(baseDir, 'ai-employee', 'skills'),
|
|
63
|
+
path_1.default.join(baseDir, 'ai-employee', 'rules'),
|
|
64
|
+
path_1.default.join(baseDir, 'ai-manager', 'jobs'),
|
|
65
|
+
path_1.default.join(baseDir, 'personalized-employee', 'jobs'),
|
|
66
|
+
path_1.default.join(baseDir, 'personalized-employee', 'skills'),
|
|
67
|
+
path_1.default.join(baseDir, 'personalized-employee', 'rules'),
|
|
68
|
+
path_1.default.join(baseDir, 'personalized-employee', 'learnings'),
|
|
69
|
+
path_1.default.join(baseDir, 'docs'),
|
|
70
|
+
];
|
|
71
|
+
for (const dir of dirs) {
|
|
72
|
+
if (!fs_1.default.existsSync(dir)) {
|
|
73
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Sync FRAIM artifacts (jobs, skills, rules, docs) from the remote server
|
|
79
|
+
* to the user-level ~/.fraim/ directory.
|
|
80
|
+
*
|
|
81
|
+
* Uses the same remote sync endpoint as project-level sync, but writes
|
|
82
|
+
* content directly to the user-level directory structure instead of under
|
|
83
|
+
* a project's fraim/ subdirectory.
|
|
84
|
+
*
|
|
85
|
+
* @param userFraimDir - Override for the target directory (for testing)
|
|
86
|
+
*/
|
|
87
|
+
async function syncUserLevelArtifacts(userFraimDir) {
|
|
88
|
+
const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
|
|
89
|
+
console.log(chalk_1.default.blue('📦 Syncing FRAIM content to user-level directory...'));
|
|
90
|
+
console.log(chalk_1.default.gray(` Target: ${baseDir}`));
|
|
91
|
+
// Ensure directory structure exists
|
|
92
|
+
ensureUserLevelDirectories(baseDir);
|
|
93
|
+
// Try to sync from remote. If this fails (no network, no key),
|
|
94
|
+
// we still have the directory structure in place.
|
|
95
|
+
try {
|
|
96
|
+
const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
|
|
97
|
+
const apiKey = loadApiKeyFromConfig(baseDir);
|
|
98
|
+
if (!apiKey) {
|
|
99
|
+
console.log(chalk_1.default.yellow('⚠️ No API key found. User-level content sync skipped.'));
|
|
100
|
+
console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const result = await syncFromRemote({
|
|
104
|
+
apiKey,
|
|
105
|
+
projectRoot: baseDir,
|
|
106
|
+
targetIsUserLevel: true,
|
|
107
|
+
skipUpdates: true
|
|
108
|
+
});
|
|
109
|
+
if (result.success) {
|
|
110
|
+
console.log(chalk_1.default.green(`✅ Synced ${result.employeeJobsSynced} ai-employee jobs, ` +
|
|
111
|
+
`${result.managerJobsSynced} ai-manager jobs, ` +
|
|
112
|
+
`${result.skillsSynced} skills, ${result.rulesSynced} rules, ` +
|
|
113
|
+
`${result.scriptsSynced} scripts, and ${result.docsSynced} docs to ~/.fraim/`));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.log(chalk_1.default.yellow(`⚠️ User-level content sync failed: ${result.error}`));
|
|
117
|
+
console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
console.log(chalk_1.default.yellow(`⚠️ User-level content sync failed: ${error.message}`));
|
|
122
|
+
console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Load API key from the user-level config file.
|
|
127
|
+
*/
|
|
128
|
+
function loadApiKeyFromConfig(userFraimDir) {
|
|
129
|
+
const configPath = path_1.default.join(userFraimDir, 'config.json');
|
|
130
|
+
if (!fs_1.default.existsSync(configPath))
|
|
131
|
+
return undefined;
|
|
132
|
+
try {
|
|
133
|
+
const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
134
|
+
return config.apiKey;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -135,8 +135,17 @@ async function syncFromRemote(options) {
|
|
|
135
135
|
error: 'No files received'
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// Helper: resolve path within the FRAIM content directory.
|
|
139
|
+
// For user-level sync, write directly to projectRoot (which IS ~/.fraim/).
|
|
140
|
+
// For project-level sync, use getWorkspaceFraimPath which prepends 'fraim/'.
|
|
141
|
+
const resolveFraimPath = (...parts) => {
|
|
142
|
+
if (options.targetIsUserLevel) {
|
|
143
|
+
return (0, path_1.join)(options.projectRoot, ...parts);
|
|
144
|
+
}
|
|
145
|
+
return (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, ...parts);
|
|
146
|
+
};
|
|
147
|
+
const lockTargets = options.targetIsUserLevel ? [] : getSyncedContentLockTargets(options.projectRoot);
|
|
148
|
+
if (!options.targetIsUserLevel && shouldLockSyncedContent()) {
|
|
140
149
|
// If previous sync locked these paths read-only, temporarily unlock before cleanup/write.
|
|
141
150
|
for (const target of lockTargets) {
|
|
142
151
|
setFileWriteLockRecursively(target, false);
|
|
@@ -146,7 +155,7 @@ async function syncFromRemote(options) {
|
|
|
146
155
|
const allJobFiles = files.filter(f => f.type === 'job');
|
|
147
156
|
const managerJobFiles = allJobFiles.filter(f => f.path.startsWith('ai-manager/'));
|
|
148
157
|
const jobFiles = allJobFiles.filter(f => !f.path.startsWith('ai-manager/'));
|
|
149
|
-
const employeeJobsDir = (
|
|
158
|
+
const employeeJobsDir = resolveFraimPath('ai-employee', 'jobs');
|
|
150
159
|
if (!(0, fs_1.existsSync)(employeeJobsDir)) {
|
|
151
160
|
(0, fs_1.mkdirSync)(employeeJobsDir, { recursive: true });
|
|
152
161
|
}
|
|
@@ -166,7 +175,7 @@ async function syncFromRemote(options) {
|
|
|
166
175
|
console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`ai-employee/jobs/${relPath}`)}`));
|
|
167
176
|
}
|
|
168
177
|
// Sync ai-manager job stubs to fraim/ai-manager/jobs/
|
|
169
|
-
const managerJobsDir = (
|
|
178
|
+
const managerJobsDir = resolveFraimPath('ai-manager', 'jobs');
|
|
170
179
|
if (!(0, fs_1.existsSync)(managerJobsDir)) {
|
|
171
180
|
(0, fs_1.mkdirSync)(managerJobsDir, { recursive: true });
|
|
172
181
|
}
|
|
@@ -187,7 +196,7 @@ async function syncFromRemote(options) {
|
|
|
187
196
|
}
|
|
188
197
|
// Sync skill STUBS to fraim/ai-employee/skills/
|
|
189
198
|
const skillFiles = files.filter(f => f.type === 'skill');
|
|
190
|
-
const skillsDir = (
|
|
199
|
+
const skillsDir = resolveFraimPath('ai-employee', 'skills');
|
|
191
200
|
if (!(0, fs_1.existsSync)(skillsDir)) {
|
|
192
201
|
(0, fs_1.mkdirSync)(skillsDir, { recursive: true });
|
|
193
202
|
}
|
|
@@ -207,7 +216,7 @@ async function syncFromRemote(options) {
|
|
|
207
216
|
}
|
|
208
217
|
// Sync rule STUBS to fraim/ai-employee/rules/
|
|
209
218
|
const ruleFiles = files.filter(f => f.type === 'rule');
|
|
210
|
-
const rulesDir = (
|
|
219
|
+
const rulesDir = resolveFraimPath('ai-employee', 'rules');
|
|
211
220
|
if (!(0, fs_1.existsSync)(rulesDir)) {
|
|
212
221
|
(0, fs_1.mkdirSync)(rulesDir, { recursive: true });
|
|
213
222
|
}
|
|
@@ -246,7 +255,7 @@ async function syncFromRemote(options) {
|
|
|
246
255
|
}
|
|
247
256
|
// Sync docs to fraim/docs/
|
|
248
257
|
const docsFiles = files.filter(f => f.type === 'docs');
|
|
249
|
-
const docsDir = (
|
|
258
|
+
const docsDir = resolveFraimPath('docs');
|
|
250
259
|
if (!(0, fs_1.existsSync)(docsDir)) {
|
|
251
260
|
(0, fs_1.mkdirSync)(docsDir, { recursive: true });
|
|
252
261
|
}
|
|
@@ -260,7 +269,7 @@ async function syncFromRemote(options) {
|
|
|
260
269
|
(0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
|
|
261
270
|
console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`docs/${file.path}`)}`));
|
|
262
271
|
}
|
|
263
|
-
if (shouldLockSyncedContent()) {
|
|
272
|
+
if (!options.targetIsUserLevel && shouldLockSyncedContent()) {
|
|
264
273
|
for (const target of lockTargets) {
|
|
265
274
|
setFileWriteLockRecursively(target, true);
|
|
266
275
|
}
|
|
@@ -49,10 +49,22 @@ const project_fraim_paths_1 = require("./project-fraim-paths");
|
|
|
49
49
|
class LocalRegistryResolver {
|
|
50
50
|
constructor(options) {
|
|
51
51
|
this.workspaceRoot = options.workspaceRoot;
|
|
52
|
+
this.effectiveFraimDir = options.effectiveFraimDir;
|
|
52
53
|
this.remoteContentResolver = options.remoteContentResolver;
|
|
53
54
|
this.parser = new inheritance_parser_1.InheritanceParser(options.maxDepth);
|
|
54
55
|
this.shouldFilter = options.shouldFilter;
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Get a path within the effective FRAIM directory.
|
|
59
|
+
* When effectiveFraimDir is set (user-level mode), joins directly with that dir.
|
|
60
|
+
* Otherwise, uses the standard getWorkspaceFraimPath which prepends 'fraim/'.
|
|
61
|
+
*/
|
|
62
|
+
getFraimPath(...parts) {
|
|
63
|
+
if (this.effectiveFraimDir) {
|
|
64
|
+
return (0, path_1.join)(this.effectiveFraimDir, ...parts);
|
|
65
|
+
}
|
|
66
|
+
return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, ...parts);
|
|
67
|
+
}
|
|
56
68
|
/**
|
|
57
69
|
* Check if a local override exists for the given path
|
|
58
70
|
*/
|
|
@@ -72,7 +84,7 @@ class LocalRegistryResolver {
|
|
|
72
84
|
if (this.hasLocalOverride(literal))
|
|
73
85
|
return literal;
|
|
74
86
|
// Deep search
|
|
75
|
-
const fullBaseDir =
|
|
87
|
+
const fullBaseDir = this.getFraimPath('personalized-employee', dir);
|
|
76
88
|
const found = this.searchFileRecursively(fullBaseDir, baseName);
|
|
77
89
|
if (found) {
|
|
78
90
|
// Convert absolute back to relative
|
|
@@ -116,7 +128,7 @@ class LocalRegistryResolver {
|
|
|
116
128
|
const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
117
129
|
// Personal overrides are in fraim/personalized-employee/
|
|
118
130
|
// We don't need a redundant 'registry/' subfolder here as the path already includes type (e.g. jobs/)
|
|
119
|
-
return
|
|
131
|
+
return this.getFraimPath('personalized-employee', normalized);
|
|
120
132
|
}
|
|
121
133
|
/**
|
|
122
134
|
* Get the full path to a locally synced FRAIM file when available.
|
|
@@ -132,18 +144,18 @@ class LocalRegistryResolver {
|
|
|
132
144
|
if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
|
|
133
145
|
const role = parts[1];
|
|
134
146
|
const subPath = parts.slice(2).join('/');
|
|
135
|
-
return
|
|
147
|
+
return this.getFraimPath(role, 'workflows', subPath);
|
|
136
148
|
}
|
|
137
149
|
// Fallback: Try ai-employee and ai-manager if no role prefix
|
|
138
150
|
const subPath = normalizedPath.substring('workflows/'.length);
|
|
139
|
-
const employeePath =
|
|
151
|
+
const employeePath = this.getFraimPath('ai-employee', 'workflows', subPath);
|
|
140
152
|
if (fs.existsSync(employeePath))
|
|
141
153
|
return employeePath;
|
|
142
|
-
const managerPath =
|
|
154
|
+
const managerPath = this.getFraimPath('ai-manager', 'workflows', subPath);
|
|
143
155
|
if (fs.existsSync(managerPath))
|
|
144
156
|
return managerPath;
|
|
145
157
|
// Fallback for non-role-prefixed direct workspace paths
|
|
146
|
-
return
|
|
158
|
+
return this.getFraimPath(normalizedPath);
|
|
147
159
|
}
|
|
148
160
|
// 2. Jobs: jobs/[role]/path -> fraim/[role]/jobs/path
|
|
149
161
|
if (normalizedPath.startsWith('jobs/')) {
|
|
@@ -151,18 +163,18 @@ class LocalRegistryResolver {
|
|
|
151
163
|
if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
|
|
152
164
|
const role = parts[1];
|
|
153
165
|
const subPath = parts.slice(2).join('/');
|
|
154
|
-
return
|
|
166
|
+
return this.getFraimPath(role, 'jobs', subPath);
|
|
155
167
|
}
|
|
156
168
|
// Fallback: Try ai-employee and ai-manager if no role prefix
|
|
157
169
|
const subPath = normalizedPath.substring('jobs/'.length);
|
|
158
|
-
const employeePath =
|
|
170
|
+
const employeePath = this.getFraimPath('ai-employee', 'jobs', subPath);
|
|
159
171
|
if (fs.existsSync(employeePath))
|
|
160
172
|
return employeePath;
|
|
161
|
-
const managerPath =
|
|
173
|
+
const managerPath = this.getFraimPath('ai-manager', 'jobs', subPath);
|
|
162
174
|
if (fs.existsSync(managerPath))
|
|
163
175
|
return managerPath;
|
|
164
176
|
// Fallback
|
|
165
|
-
return
|
|
177
|
+
return this.getFraimPath(normalizedPath);
|
|
166
178
|
}
|
|
167
179
|
// 3. Rules: [role]/rules/path -> fraim/[role]/rules/path
|
|
168
180
|
if (normalizedPath.includes('/rules/')) {
|
|
@@ -170,16 +182,16 @@ class LocalRegistryResolver {
|
|
|
170
182
|
// Extract the part after "rules/"
|
|
171
183
|
const rulesIdx = normalizedPath.indexOf('rules/');
|
|
172
184
|
const subPath = normalizedPath.substring(rulesIdx + 'rules/'.length);
|
|
173
|
-
return
|
|
185
|
+
return this.getFraimPath(role, 'rules', subPath);
|
|
174
186
|
}
|
|
175
187
|
// 4. Skills: skills/path -> fraim/ai-employee/skills/path (default to ai-employee)
|
|
176
188
|
if (normalizedPath.startsWith('skills/')) {
|
|
177
189
|
const subPath = normalizedPath.substring('skills/'.length);
|
|
178
|
-
return
|
|
190
|
+
return this.getFraimPath('ai-employee', 'skills', subPath);
|
|
179
191
|
}
|
|
180
192
|
// 5. Rules: rules/path -> fraim/ai-employee/rules/path (default to ai-employee)
|
|
181
193
|
if (normalizedPath.startsWith('rules/')) {
|
|
182
|
-
return
|
|
194
|
+
return this.getFraimPath('ai-employee', normalizedPath);
|
|
183
195
|
}
|
|
184
196
|
return null;
|
|
185
197
|
}
|
|
@@ -580,7 +592,7 @@ class LocalRegistryResolver {
|
|
|
580
592
|
const items = [];
|
|
581
593
|
const dirs = ['jobs'];
|
|
582
594
|
for (const dir of dirs) {
|
|
583
|
-
const localDir =
|
|
595
|
+
const localDir = this.getFraimPath('personalized-employee', dir);
|
|
584
596
|
if (fs.existsSync(localDir)) {
|
|
585
597
|
const relPaths = this.collectLocalMarkdownPaths(localDir);
|
|
586
598
|
for (const rel of relPaths) {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.WORKSPACE_SYNCED_CONTENT_DIRS = exports.WORKSPACE_FRAIM_DIRNAME = void 0;
|
|
4
7
|
exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
|
|
@@ -6,8 +9,11 @@ exports.workspaceFraimExists = workspaceFraimExists;
|
|
|
6
9
|
exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
|
|
7
10
|
exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
|
|
8
11
|
exports.getWorkspaceFraimDisplayPath = getWorkspaceFraimDisplayPath;
|
|
12
|
+
exports.getUserFraimDirPath = getUserFraimDirPath;
|
|
13
|
+
exports.getEffectiveFraimDir = getEffectiveFraimDir;
|
|
9
14
|
const fs_1 = require("fs");
|
|
10
15
|
const path_1 = require("path");
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
11
17
|
exports.WORKSPACE_FRAIM_DIRNAME = 'fraim';
|
|
12
18
|
exports.WORKSPACE_SYNCED_CONTENT_DIRS = [
|
|
13
19
|
'workflows',
|
|
@@ -36,3 +42,34 @@ function getWorkspaceFraimDisplayPath(relativePath = '') {
|
|
|
36
42
|
? `${exports.WORKSPACE_FRAIM_DIRNAME}/${normalized}`
|
|
37
43
|
: `${exports.WORKSPACE_FRAIM_DIRNAME}/`;
|
|
38
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Get the user-level FRAIM directory (~/.fraim/).
|
|
47
|
+
* Can be overridden with FRAIM_USER_DIR env var for testing.
|
|
48
|
+
*/
|
|
49
|
+
function getUserFraimDirPath() {
|
|
50
|
+
return process.env.FRAIM_USER_DIR || (0, path_1.join)(os_1.default.homedir(), '.fraim');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Determine the effective FRAIM content root directory.
|
|
54
|
+
*
|
|
55
|
+
* Shadow semantics: if the project has a local fraim/ directory, it completely
|
|
56
|
+
* shadows the user-level ~/.fraim/ — no mixing, no layering.
|
|
57
|
+
*
|
|
58
|
+
* @param projectRoot - The project/workspace root directory
|
|
59
|
+
* @param userFraimDir - Optional override for the user-level dir (for testing)
|
|
60
|
+
* @returns The effective fraim content root directory path, or '' if neither exists
|
|
61
|
+
*/
|
|
62
|
+
function getEffectiveFraimDir(projectRoot = process.cwd(), userFraimDir) {
|
|
63
|
+
// 1. Check for project-level fraim/ directory
|
|
64
|
+
const projectFraimDir = getWorkspaceFraimDir(projectRoot);
|
|
65
|
+
if ((0, fs_1.existsSync)(projectFraimDir)) {
|
|
66
|
+
return projectFraimDir;
|
|
67
|
+
}
|
|
68
|
+
// 2. Fall back to user-level ~/.fraim/
|
|
69
|
+
const userDir = userFraimDir || getUserFraimDirPath();
|
|
70
|
+
if ((0, fs_1.existsSync)(userDir)) {
|
|
71
|
+
return userDir;
|
|
72
|
+
}
|
|
73
|
+
// 3. Neither exists
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight OTLP HTTP Metrics Receiver
|
|
4
|
+
*
|
|
5
|
+
* Accepts OTLP metric pushes from Claude Code when configured with:
|
|
6
|
+
* OTEL_METRICS_EXPORTER=otlp
|
|
7
|
+
* OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
|
8
|
+
* OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
9
|
+
*
|
|
10
|
+
* Multiple Claude Code sessions share one receiver on port 4318.
|
|
11
|
+
* Snapshots are stored per session ID and queryable via HTTP so any
|
|
12
|
+
* FRAIM proxy (not just the one that started the receiver) can fetch
|
|
13
|
+
* its own session's token data.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.parseOtlpMetrics = parseOtlpMetrics;
|
|
17
|
+
exports.fetchSnapshot = fetchSnapshot;
|
|
18
|
+
exports.startOtlpReceiver = startOtlpReceiver;
|
|
19
|
+
exports.stopOtlpReceiver = stopOtlpReceiver;
|
|
20
|
+
const http_1 = require("http");
|
|
21
|
+
const DEFAULT_PORT = 4318;
|
|
22
|
+
/** Stored token snapshots keyed by Claude Code session ID */
|
|
23
|
+
const snapshots = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* Parse OTLP HTTP JSON metrics payload and extract token/cost data.
|
|
26
|
+
*
|
|
27
|
+
* OTLP JSON structure:
|
|
28
|
+
* { resourceMetrics: [{ scopeMetrics: [{ metrics: [{ name, sum: { dataPoints } }] }] }] }
|
|
29
|
+
*/
|
|
30
|
+
function parseOtlpMetrics(body) {
|
|
31
|
+
if (!body?.resourceMetrics)
|
|
32
|
+
return null;
|
|
33
|
+
let inputTokens = 0;
|
|
34
|
+
let outputTokens = 0;
|
|
35
|
+
let cacheReadTokens = 0;
|
|
36
|
+
let cacheCreationTokens = 0;
|
|
37
|
+
let costUsd = 0;
|
|
38
|
+
let claudeSessionId = null;
|
|
39
|
+
let model = null;
|
|
40
|
+
let foundTokenMetric = false;
|
|
41
|
+
for (const rm of body.resourceMetrics) {
|
|
42
|
+
for (const sm of rm.scopeMetrics || []) {
|
|
43
|
+
for (const metric of sm.metrics || []) {
|
|
44
|
+
const dataPoints = metric.sum?.dataPoints || metric.gauge?.dataPoints || [];
|
|
45
|
+
if (metric.name === 'claude_code.token.usage') {
|
|
46
|
+
foundTokenMetric = true;
|
|
47
|
+
for (const dp of dataPoints) {
|
|
48
|
+
const attrs = parseAttributes(dp.attributes);
|
|
49
|
+
const value = Number(dp.asInt ?? dp.asDouble ?? dp.value ?? 0);
|
|
50
|
+
switch (attrs['type']) {
|
|
51
|
+
case 'input':
|
|
52
|
+
inputTokens += value;
|
|
53
|
+
break;
|
|
54
|
+
case 'output':
|
|
55
|
+
outputTokens += value;
|
|
56
|
+
break;
|
|
57
|
+
case 'cacheRead':
|
|
58
|
+
cacheReadTokens += value;
|
|
59
|
+
break;
|
|
60
|
+
case 'cacheCreation':
|
|
61
|
+
cacheCreationTokens += value;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
claudeSessionId = attrs['session.id'] || claudeSessionId;
|
|
65
|
+
model = attrs['model'] || model;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (metric.name === 'claude_code.cost.usage') {
|
|
69
|
+
for (const dp of dataPoints) {
|
|
70
|
+
const value = Number(dp.asDouble ?? dp.asInt ?? dp.value ?? 0);
|
|
71
|
+
costUsd += value;
|
|
72
|
+
const attrs = parseAttributes(dp.attributes);
|
|
73
|
+
claudeSessionId = attrs['session.id'] || claudeSessionId;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!foundTokenMetric)
|
|
80
|
+
return null;
|
|
81
|
+
return {
|
|
82
|
+
inputTokens,
|
|
83
|
+
outputTokens,
|
|
84
|
+
cacheReadTokens,
|
|
85
|
+
cacheCreationTokens,
|
|
86
|
+
costUsd,
|
|
87
|
+
claudeSessionId,
|
|
88
|
+
model,
|
|
89
|
+
capturedAt: new Date(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse OTLP attribute array into a key-value map.
|
|
94
|
+
* Attributes come as: [{ key: "name", value: { stringValue: "x" } }, ...]
|
|
95
|
+
*/
|
|
96
|
+
function parseAttributes(attrs) {
|
|
97
|
+
const result = {};
|
|
98
|
+
if (!Array.isArray(attrs))
|
|
99
|
+
return result;
|
|
100
|
+
for (const attr of attrs) {
|
|
101
|
+
if (attr.key && attr.value) {
|
|
102
|
+
result[attr.key] = attr.value.stringValue
|
|
103
|
+
?? attr.value.intValue
|
|
104
|
+
?? attr.value.doubleValue
|
|
105
|
+
?? String(attr.value.value ?? '');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Parse URL query string into key-value pairs.
|
|
112
|
+
*/
|
|
113
|
+
function parseQuery(url) {
|
|
114
|
+
const idx = url.indexOf('?');
|
|
115
|
+
if (idx < 0)
|
|
116
|
+
return {};
|
|
117
|
+
const params = {};
|
|
118
|
+
for (const part of url.slice(idx + 1).split('&')) {
|
|
119
|
+
const [key, val] = part.split('=');
|
|
120
|
+
if (key)
|
|
121
|
+
params[decodeURIComponent(key)] = decodeURIComponent(val || '');
|
|
122
|
+
}
|
|
123
|
+
return params;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Handle incoming HTTP requests.
|
|
127
|
+
*/
|
|
128
|
+
function handleRequest(req, res) {
|
|
129
|
+
const url = req.url || '';
|
|
130
|
+
const pathname = url.split('?')[0];
|
|
131
|
+
// OTLP metrics push endpoint
|
|
132
|
+
if (req.method === 'POST' && pathname === '/v1/metrics') {
|
|
133
|
+
let body = '';
|
|
134
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
135
|
+
req.on('end', () => {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(body);
|
|
138
|
+
const snapshot = parseOtlpMetrics(parsed);
|
|
139
|
+
if (snapshot && snapshot.claudeSessionId) {
|
|
140
|
+
snapshots.set(snapshot.claudeSessionId, snapshot);
|
|
141
|
+
}
|
|
142
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
143
|
+
res.end('{}');
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
res.writeHead(400);
|
|
147
|
+
res.end('{"error":"invalid json"}');
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Query snapshot — used by any FRAIM proxy to fetch token data
|
|
153
|
+
// ?sessionId=X returns that session's snapshot
|
|
154
|
+
// ?latest=true returns the most recently updated snapshot
|
|
155
|
+
if (req.method === 'GET' && pathname === '/v1/snapshot') {
|
|
156
|
+
const query = parseQuery(url);
|
|
157
|
+
let snapshot;
|
|
158
|
+
if (query['sessionId']) {
|
|
159
|
+
snapshot = snapshots.get(query['sessionId']);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// Return most recently captured snapshot across all sessions
|
|
163
|
+
let latest;
|
|
164
|
+
for (const s of snapshots.values()) {
|
|
165
|
+
if (!latest || s.capturedAt > latest.capturedAt) {
|
|
166
|
+
latest = s;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
snapshot = latest;
|
|
170
|
+
}
|
|
171
|
+
if (snapshot) {
|
|
172
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
173
|
+
res.end(JSON.stringify(snapshot));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end('{"error":"no snapshot available"}');
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Health check
|
|
182
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
183
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
184
|
+
res.end(JSON.stringify({
|
|
185
|
+
status: 'ok',
|
|
186
|
+
sessionCount: snapshots.size,
|
|
187
|
+
sessions: [...snapshots.keys()],
|
|
188
|
+
}));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(404);
|
|
192
|
+
res.end('');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Fetch a token snapshot from the OTLP receiver via HTTP.
|
|
196
|
+
* Works whether this process owns the receiver or another proxy does.
|
|
197
|
+
*
|
|
198
|
+
* @param sessionId If provided, fetches that specific session's snapshot.
|
|
199
|
+
* If omitted, fetches the most recently updated snapshot.
|
|
200
|
+
* @param log Optional logging function
|
|
201
|
+
*/
|
|
202
|
+
async function fetchSnapshot(sessionId, log) {
|
|
203
|
+
const port = parseInt(process.env.FRAIM_OTLP_PORT || String(DEFAULT_PORT), 10);
|
|
204
|
+
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
|
|
205
|
+
try {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
208
|
+
const resp = await fetch(`http://127.0.0.1:${port}/v1/snapshot${query}`, { signal: controller.signal });
|
|
209
|
+
clearTimeout(timeout);
|
|
210
|
+
if (!resp.ok)
|
|
211
|
+
return null;
|
|
212
|
+
const data = await resp.json();
|
|
213
|
+
if (data.inputTokens !== undefined) {
|
|
214
|
+
return {
|
|
215
|
+
...data,
|
|
216
|
+
capturedAt: new Date(data.capturedAt),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
log?.('OTLP receiver not reachable — no token snapshot available');
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Start the OTLP metrics receiver HTTP server.
|
|
228
|
+
* Only one receiver runs per machine — if port is in use, that's fine;
|
|
229
|
+
* this proxy will query the existing receiver via HTTP.
|
|
230
|
+
*
|
|
231
|
+
* @param log Optional logging function
|
|
232
|
+
* @returns Server info if started, null if port was in use (not an error)
|
|
233
|
+
*/
|
|
234
|
+
function startOtlpReceiver(log) {
|
|
235
|
+
const port = parseInt(process.env.FRAIM_OTLP_PORT || String(DEFAULT_PORT), 10);
|
|
236
|
+
try {
|
|
237
|
+
const server = (0, http_1.createServer)(handleRequest);
|
|
238
|
+
server.on('error', (err) => {
|
|
239
|
+
if (err.code === 'EADDRINUSE') {
|
|
240
|
+
log?.(`OTLP receiver: port ${port} in use — will query existing receiver via HTTP`);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
log?.(`OTLP receiver error: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
server.listen(port, '127.0.0.1', () => {
|
|
247
|
+
log?.(`OTLP metrics receiver listening on http://127.0.0.1:${port}/v1/metrics`);
|
|
248
|
+
});
|
|
249
|
+
return { server, port };
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
log?.(`Failed to start OTLP receiver: ${err.message}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Stop the OTLP receiver and clear stored snapshots.
|
|
258
|
+
*/
|
|
259
|
+
function stopOtlpReceiver(server) {
|
|
260
|
+
server.close();
|
|
261
|
+
snapshots.clear();
|
|
262
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Prometheus Scraper for Claude Code OTel Token Usage Metrics
|
|
4
|
+
*
|
|
5
|
+
* Scrapes Claude Code's local Prometheus endpoint to capture cumulative
|
|
6
|
+
* token usage counters. Returns raw snapshot values — delta computation
|
|
7
|
+
* happens server-side per architecture constraint 15.2.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.parsePrometheusText = parsePrometheusText;
|
|
11
|
+
exports.extractTokenSnapshot = extractTokenSnapshot;
|
|
12
|
+
exports.scrapeTokenSnapshot = scrapeTokenSnapshot;
|
|
13
|
+
/** Candidate Prometheus endpoint ports (OTel SDK defaults) */
|
|
14
|
+
const CANDIDATE_PORTS = [9464, 8888];
|
|
15
|
+
/** Candidate metric names for token usage (OTel dots→underscores, optional suffixes) */
|
|
16
|
+
const TOKEN_METRIC_CANDIDATES = [
|
|
17
|
+
'claude_code_token_usage_total',
|
|
18
|
+
'claude_code_token_usage',
|
|
19
|
+
'claude_code_token_usage_tokens_total',
|
|
20
|
+
'claude_code_token_usage_tokens',
|
|
21
|
+
];
|
|
22
|
+
/** Candidate metric names for cost usage */
|
|
23
|
+
const COST_METRIC_CANDIDATES = [
|
|
24
|
+
'claude_code_cost_usage_total',
|
|
25
|
+
'claude_code_cost_usage',
|
|
26
|
+
'claude_code_cost_usage_usd_total',
|
|
27
|
+
'claude_code_cost_usage_usd',
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Parse Prometheus text exposition format into structured metrics.
|
|
31
|
+
*
|
|
32
|
+
* Format per line: metric_name{label1="val1",label2="val2"} value [timestamp]
|
|
33
|
+
* Lines starting with # are comments (HELP, TYPE). Empty lines are skipped.
|
|
34
|
+
*/
|
|
35
|
+
function parsePrometheusText(text) {
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
if (line.startsWith('#') || line.trim() === '')
|
|
39
|
+
continue;
|
|
40
|
+
// Match: metric_name{labels} value
|
|
41
|
+
const labeledMatch = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+([\d.eE+-]+)/);
|
|
42
|
+
if (labeledMatch) {
|
|
43
|
+
const [, name, labelsStr, valueStr] = labeledMatch;
|
|
44
|
+
const labels = {};
|
|
45
|
+
for (const pair of labelsStr.matchAll(/(\w+)="([^"]*)"/g)) {
|
|
46
|
+
labels[pair[1]] = pair[2];
|
|
47
|
+
}
|
|
48
|
+
results.push({ name, labels, value: parseFloat(valueStr) });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Match: metric_name value (no labels)
|
|
52
|
+
const noLabelMatch = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([\d.eE+-]+)/);
|
|
53
|
+
if (noLabelMatch) {
|
|
54
|
+
const [, name, valueStr] = noLabelMatch;
|
|
55
|
+
results.push({ name, labels: {}, value: parseFloat(valueStr) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Extract a TokenSnapshot from parsed Prometheus metrics.
|
|
62
|
+
* Tries multiple candidate metric names for resilience to SDK version changes.
|
|
63
|
+
*/
|
|
64
|
+
function extractTokenSnapshot(metrics) {
|
|
65
|
+
const snapshot = {
|
|
66
|
+
inputTokens: 0,
|
|
67
|
+
outputTokens: 0,
|
|
68
|
+
cacheReadTokens: 0,
|
|
69
|
+
cacheCreationTokens: 0,
|
|
70
|
+
costUsd: 0,
|
|
71
|
+
claudeSessionId: null,
|
|
72
|
+
model: null,
|
|
73
|
+
capturedAt: new Date(),
|
|
74
|
+
};
|
|
75
|
+
for (const m of metrics) {
|
|
76
|
+
if (TOKEN_METRIC_CANDIDATES.includes(m.name)) {
|
|
77
|
+
const type = m.labels['type'];
|
|
78
|
+
switch (type) {
|
|
79
|
+
case 'input':
|
|
80
|
+
snapshot.inputTokens = m.value;
|
|
81
|
+
break;
|
|
82
|
+
case 'output':
|
|
83
|
+
snapshot.outputTokens = m.value;
|
|
84
|
+
break;
|
|
85
|
+
case 'cacheRead':
|
|
86
|
+
snapshot.cacheReadTokens = m.value;
|
|
87
|
+
break;
|
|
88
|
+
case 'cacheCreation':
|
|
89
|
+
snapshot.cacheCreationTokens = m.value;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
snapshot.claudeSessionId = m.labels['session_id'] || snapshot.claudeSessionId;
|
|
93
|
+
snapshot.model = m.labels['model'] || snapshot.model;
|
|
94
|
+
}
|
|
95
|
+
if (COST_METRIC_CANDIDATES.includes(m.name)) {
|
|
96
|
+
snapshot.costUsd += m.value;
|
|
97
|
+
snapshot.claudeSessionId = m.labels['session_id'] || snapshot.claudeSessionId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return snapshot;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Attempt to fetch Prometheus metrics from a candidate endpoint.
|
|
104
|
+
* Returns response text or null on failure.
|
|
105
|
+
*/
|
|
106
|
+
async function tryFetch(port, timeoutMs) {
|
|
107
|
+
try {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
110
|
+
const resp = await fetch(`http://localhost:${port}/metrics`, {
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
if (resp.ok) {
|
|
115
|
+
return await resp.text();
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Scrape Claude Code's Prometheus endpoint and return a TokenSnapshot.
|
|
125
|
+
*
|
|
126
|
+
* Returns null if:
|
|
127
|
+
* - Prometheus endpoint is not available (OTel not enabled)
|
|
128
|
+
* - Scrape times out (>2s by default)
|
|
129
|
+
* - No matching token metrics found
|
|
130
|
+
*
|
|
131
|
+
* @param log Optional logging function for debug output
|
|
132
|
+
*/
|
|
133
|
+
async function scrapeTokenSnapshot(log) {
|
|
134
|
+
const configuredPort = process.env.FRAIM_PROMETHEUS_PORT;
|
|
135
|
+
const ports = configuredPort ? [parseInt(configuredPort, 10)] : CANDIDATE_PORTS;
|
|
136
|
+
const timeoutMs = 2000;
|
|
137
|
+
for (const port of ports) {
|
|
138
|
+
const text = await tryFetch(port, timeoutMs);
|
|
139
|
+
if (text && text.includes('#')) {
|
|
140
|
+
const metrics = parsePrometheusText(text);
|
|
141
|
+
const snapshot = extractTokenSnapshot(metrics);
|
|
142
|
+
// Only return if we actually found token metrics
|
|
143
|
+
if (snapshot.inputTokens > 0 || snapshot.outputTokens > 0) {
|
|
144
|
+
log?.(`Captured token snapshot from localhost:${port} (input=${snapshot.inputTokens}, output=${snapshot.outputTokens})`);
|
|
145
|
+
return snapshot;
|
|
146
|
+
}
|
|
147
|
+
log?.(`Prometheus endpoint found at localhost:${port} but no token metrics matched`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
log?.('No Prometheus endpoint available — token snapshot skipped');
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
@@ -31,6 +31,7 @@ const local_registry_resolver_1 = require("../core/utils/local-registry-resolver
|
|
|
31
31
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
32
32
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
33
33
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
34
|
+
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
34
35
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
35
36
|
/**
|
|
36
37
|
* Handle template substitution logic separately for better testability
|
|
@@ -398,6 +399,7 @@ class FraimLocalMCPServer {
|
|
|
398
399
|
this.machineInfo = null;
|
|
399
400
|
this.repoInfo = null;
|
|
400
401
|
this.engine = null;
|
|
402
|
+
this.otlpServer = null;
|
|
401
403
|
this.writer = writer || process.stdout.write.bind(process.stdout);
|
|
402
404
|
this.remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
|
|
403
405
|
this.apiKey = this.loadApiKey();
|
|
@@ -415,6 +417,8 @@ class FraimLocalMCPServer {
|
|
|
415
417
|
// Initialize usage collector
|
|
416
418
|
this.usageCollector = new usage_collector_js_1.UsageCollector();
|
|
417
419
|
this.log('📊 Usage analytics collector initialized');
|
|
420
|
+
// Start OTLP metrics receiver for Claude Code token telemetry
|
|
421
|
+
this.otlpServer = (0, otlp_metrics_receiver_js_1.startOtlpReceiver)((msg) => this.log(`📊 ${msg}`));
|
|
418
422
|
}
|
|
419
423
|
/**
|
|
420
424
|
* Load API key from environment variable or user config file
|
|
@@ -1212,8 +1216,16 @@ class FraimLocalMCPServer {
|
|
|
1212
1216
|
getRegistryResolver(requestSessionId) {
|
|
1213
1217
|
const projectRoot = this.findProjectRoot();
|
|
1214
1218
|
this.log(`🔍 getRegistryResolver: projectRoot = ${projectRoot}`);
|
|
1215
|
-
|
|
1216
|
-
|
|
1219
|
+
// Determine effective FRAIM dir using shadow semantics
|
|
1220
|
+
const { getEffectiveFraimDir } = require('../core/utils/project-fraim-paths');
|
|
1221
|
+
const effectiveFraimDir = projectRoot
|
|
1222
|
+
? getEffectiveFraimDir(projectRoot)
|
|
1223
|
+
: getEffectiveFraimDir(process.cwd());
|
|
1224
|
+
if (effectiveFraimDir) {
|
|
1225
|
+
this.log(`📂 Effective FRAIM dir: ${effectiveFraimDir}`);
|
|
1226
|
+
}
|
|
1227
|
+
if (!projectRoot && !effectiveFraimDir) {
|
|
1228
|
+
this.log('⚠️ No project root or user-level FRAIM found, override resolution disabled');
|
|
1217
1229
|
// Return a resolver that always falls back to remote
|
|
1218
1230
|
return new local_registry_resolver_1.LocalRegistryResolver({
|
|
1219
1231
|
workspaceRoot: process.cwd(),
|
|
@@ -1224,8 +1236,15 @@ class FraimLocalMCPServer {
|
|
|
1224
1236
|
});
|
|
1225
1237
|
}
|
|
1226
1238
|
else {
|
|
1239
|
+
// Determine if we need effectiveFraimDir override.
|
|
1240
|
+
// If the effective dir is user-level (~/.fraim/), it's NOT under projectRoot/fraim/,
|
|
1241
|
+
// so we pass it as effectiveFraimDir to bypass the getWorkspaceFraimPath logic.
|
|
1242
|
+
const workspaceRoot = projectRoot || process.cwd();
|
|
1243
|
+
const projectFraimDir = projectRoot ? (0, path_1.join)(projectRoot, 'fraim') : '';
|
|
1244
|
+
const needsEffectiveDirOverride = effectiveFraimDir && effectiveFraimDir !== projectFraimDir;
|
|
1227
1245
|
return new local_registry_resolver_1.LocalRegistryResolver({
|
|
1228
|
-
workspaceRoot
|
|
1246
|
+
workspaceRoot,
|
|
1247
|
+
...(needsEffectiveDirOverride ? { effectiveFraimDir } : {}),
|
|
1229
1248
|
shouldFilter: (content) => this.isStub(content),
|
|
1230
1249
|
remoteContentResolver: async (path) => {
|
|
1231
1250
|
// Fetch parent content from remote for inheritance
|
|
@@ -1824,6 +1843,9 @@ class FraimLocalMCPServer {
|
|
|
1824
1843
|
const cleanup = () => {
|
|
1825
1844
|
clearInterval(uploadInterval);
|
|
1826
1845
|
this.usageCollector.shutdown();
|
|
1846
|
+
if (this.otlpServer?.server) {
|
|
1847
|
+
(0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
|
|
1848
|
+
}
|
|
1827
1849
|
};
|
|
1828
1850
|
process.stdin.on('data', async (chunk) => {
|
|
1829
1851
|
buffer += chunk;
|
|
@@ -1843,7 +1865,7 @@ class FraimLocalMCPServer {
|
|
|
1843
1865
|
// Only send response if we got one (null means we handled it internally)
|
|
1844
1866
|
if (response) {
|
|
1845
1867
|
// Collect usage for all tools/call requests before sending response
|
|
1846
|
-
this.collectUsageForResponse(message, response);
|
|
1868
|
+
await this.collectUsageForResponse(message, response);
|
|
1847
1869
|
process.stdout.write(JSON.stringify(response) + '\n');
|
|
1848
1870
|
}
|
|
1849
1871
|
}
|
|
@@ -1900,7 +1922,7 @@ class FraimLocalMCPServer {
|
|
|
1900
1922
|
/**
|
|
1901
1923
|
* Collect usage analytics for tools/call requests
|
|
1902
1924
|
*/
|
|
1903
|
-
collectUsageForResponse(request, response) {
|
|
1925
|
+
async collectUsageForResponse(request, response) {
|
|
1904
1926
|
// Only collect usage for tools/call requests
|
|
1905
1927
|
if (request.method !== 'tools/call') {
|
|
1906
1928
|
return;
|
|
@@ -1919,6 +1941,19 @@ class FraimLocalMCPServer {
|
|
|
1919
1941
|
const afterCount = this.usageCollector.getEventCount();
|
|
1920
1942
|
if (afterCount > beforeCount) {
|
|
1921
1943
|
this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
|
|
1944
|
+
// Fetch token snapshot from OTLP receiver for seekMentoring calls
|
|
1945
|
+
if (toolName === 'seekMentoring') {
|
|
1946
|
+
try {
|
|
1947
|
+
const snapshot = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(undefined, (msg) => this.log(`📊 ${msg}`));
|
|
1948
|
+
if (snapshot) {
|
|
1949
|
+
this.usageCollector.attachTokenSnapshot(snapshot);
|
|
1950
|
+
this.log(`📊 🔢 Token snapshot attached (input=${snapshot.inputTokens}, output=${snapshot.outputTokens}, session=${snapshot.claudeSessionId})`);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
catch (err) {
|
|
1954
|
+
this.log(`📊 ⚠️ Token snapshot fetch failed (non-blocking): ${err.message}`);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1922
1957
|
}
|
|
1923
1958
|
else {
|
|
1924
1959
|
this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
|
|
@@ -163,6 +163,15 @@ class UsageCollector {
|
|
|
163
163
|
this.events = [];
|
|
164
164
|
return eventsToUpload;
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Attach a token snapshot to the most recently queued event.
|
|
168
|
+
* Used to associate Prometheus-scraped token data with seekMentoring events.
|
|
169
|
+
*/
|
|
170
|
+
attachTokenSnapshot(snapshot) {
|
|
171
|
+
if (this.events.length > 0) {
|
|
172
|
+
this.events[this.events.length - 1].tokenSnapshot = snapshot;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
166
175
|
/**
|
|
167
176
|
* Get current event count
|
|
168
177
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.106",
|
|
4
4
|
"description": "FRAIM v2: Framework for Rigor-based AI Management - Transform from solo developer to AI manager orchestrating production-ready code with enterprise-grade discipline",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|