fraim-framework 2.0.124 → 2.0.126
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/fraim.js +1 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +56 -17
- package/dist/src/cli/utils/agent-adapters.js +2 -2
- package/dist/src/cli/utils/project-bootstrap.js +4 -3
- package/dist/src/core/quality-evidence.js +81 -8
- package/dist/src/core/utils/git-utils.js +21 -4
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/workflow-parser.js +3 -5
- package/dist/src/local-mcp-server/agent-token-prices.js +114 -0
- package/dist/src/local-mcp-server/codex-token-adapter.js +232 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +21 -8
- package/dist/src/local-mcp-server/stdio-server.js +29 -8
- package/dist/src/local-mcp-server/token-adapter-registry.js +64 -0
- package/dist/src/local-mcp-server/usage-collector.js +25 -0
- package/index.js +83 -83
- package/package.json +5 -1
- package/dist/src/cli/services/device-flow-service.js +0 -83
- package/dist/src/local-mcp-server/prometheus-scraper.js +0 -152
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Codex Token Adapter — Issue #330 / R1.3
|
|
4
|
+
*
|
|
5
|
+
* Reads Codex CLI's per-session JSONL log at
|
|
6
|
+
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`, sums
|
|
7
|
+
* cumulative per-turn token counts, and converts to USD via the static
|
|
8
|
+
* price table.
|
|
9
|
+
*
|
|
10
|
+
* Verified against Codex CLI 0.125.0 session files. Token counts live at
|
|
11
|
+
* `event_msg.payload.info.total_token_usage.{input_tokens, output_tokens,
|
|
12
|
+
* cached_input_tokens, reasoning_output_tokens}`. Model lives on
|
|
13
|
+
* `turn_context.payload.model` (latest turn wins).
|
|
14
|
+
*
|
|
15
|
+
* Compliance: only counter values + model identifier exit this module.
|
|
16
|
+
* Prompt content, file paths, and other JSONL fields are explicitly
|
|
17
|
+
* dropped so PII never enters telemetry (C1).
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.captureCodexSnapshot = captureCodexSnapshot;
|
|
54
|
+
const fs = __importStar(require("node:fs"));
|
|
55
|
+
const os = __importStar(require("node:os"));
|
|
56
|
+
const path = __importStar(require("node:path"));
|
|
57
|
+
const agent_token_prices_js_1 = require("./agent-token-prices.js");
|
|
58
|
+
/**
|
|
59
|
+
* Read the Codex session file and produce a TokenSnapshot. Returns null
|
|
60
|
+
* (with optional log warning) if the file is missing, unreadable, or
|
|
61
|
+
* contains no token_count events.
|
|
62
|
+
*/
|
|
63
|
+
async function captureCodexSnapshot(opts = {}) {
|
|
64
|
+
const timeoutMs = opts.timeoutMs ?? 250;
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
const deadline = start + timeoutMs;
|
|
67
|
+
const file = opts.sessionFile ?? findLatestSessionFile(opts.sessionsRoot);
|
|
68
|
+
if (!file) {
|
|
69
|
+
opts.log?.('codex adapter: no session file found in ~/.codex/sessions');
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
let contents;
|
|
73
|
+
try {
|
|
74
|
+
contents = await fs.promises.readFile(file, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
opts.log?.(`codex adapter: cannot read ${file}: ${err.message}`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
let model = null;
|
|
81
|
+
let sessionId = null;
|
|
82
|
+
// Codex `total_token_usage` is cumulative; we use the highest seen
|
|
83
|
+
// (= most-recent cumulative) so partial reads during an active write
|
|
84
|
+
// don't regress.
|
|
85
|
+
let lastInput = 0;
|
|
86
|
+
let lastOutput = 0;
|
|
87
|
+
let lastCacheRead = 0;
|
|
88
|
+
let lastReasoningOutput = 0;
|
|
89
|
+
let foundAny = false;
|
|
90
|
+
const lines = contents.split('\n');
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (Date.now() > deadline) {
|
|
93
|
+
opts.log?.(`codex adapter: timeout after ${timeoutMs}ms; partial result`);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (!line.trim())
|
|
97
|
+
continue;
|
|
98
|
+
let row;
|
|
99
|
+
try {
|
|
100
|
+
row = JSON.parse(line);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue; // skip malformed lines
|
|
104
|
+
}
|
|
105
|
+
if (!row || typeof row !== 'object')
|
|
106
|
+
continue;
|
|
107
|
+
// session_meta carries session id (model is not on this event).
|
|
108
|
+
if (row.type === 'session_meta' && row.payload && typeof row.payload === 'object') {
|
|
109
|
+
if (typeof row.payload.id === 'string')
|
|
110
|
+
sessionId = row.payload.id;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// turn_context carries the active model. Latest turn wins.
|
|
114
|
+
if (row.type === 'turn_context' && row.payload && typeof row.payload === 'object') {
|
|
115
|
+
if (typeof row.payload.model === 'string')
|
|
116
|
+
model = row.payload.model;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// event_msg with payload.type === 'token_count' carries cumulative usage.
|
|
120
|
+
if (row.type === 'event_msg' && row.payload && row.payload.type === 'token_count') {
|
|
121
|
+
const info = row.payload.info;
|
|
122
|
+
if (info && typeof info === 'object' && info.total_token_usage && typeof info.total_token_usage === 'object') {
|
|
123
|
+
const t = info.total_token_usage;
|
|
124
|
+
const i = numberOrZero(t.input_tokens);
|
|
125
|
+
const o = numberOrZero(t.output_tokens);
|
|
126
|
+
const cr = numberOrZero(t.cached_input_tokens);
|
|
127
|
+
const r = numberOrZero(t.reasoning_output_tokens);
|
|
128
|
+
if (i > lastInput)
|
|
129
|
+
lastInput = i;
|
|
130
|
+
if (o > lastOutput)
|
|
131
|
+
lastOutput = o;
|
|
132
|
+
if (cr > lastCacheRead)
|
|
133
|
+
lastCacheRead = cr;
|
|
134
|
+
if (r > lastReasoningOutput)
|
|
135
|
+
lastReasoningOutput = r;
|
|
136
|
+
foundAny = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!foundAny)
|
|
141
|
+
return null;
|
|
142
|
+
// Reasoning-output tokens are billed as output tokens by OpenAI today;
|
|
143
|
+
// fold them into the output count so the cost computation lines up
|
|
144
|
+
// with the user's bill.
|
|
145
|
+
const counts = {
|
|
146
|
+
inputTokens: lastInput,
|
|
147
|
+
outputTokens: lastOutput + lastReasoningOutput,
|
|
148
|
+
cacheReadTokens: lastCacheRead,
|
|
149
|
+
cacheCreationTokens: 0,
|
|
150
|
+
};
|
|
151
|
+
const costUsd = (0, agent_token_prices_js_1.computeCostUsd)('codex', model ?? undefined, counts);
|
|
152
|
+
// C1 — only the listed fields exit this module. No spread of row data,
|
|
153
|
+
// no prompt/content fields, no file paths.
|
|
154
|
+
return {
|
|
155
|
+
inputTokens: counts.inputTokens,
|
|
156
|
+
outputTokens: counts.outputTokens,
|
|
157
|
+
cacheReadTokens: counts.cacheReadTokens,
|
|
158
|
+
cacheCreationTokens: counts.cacheCreationTokens,
|
|
159
|
+
costUsd,
|
|
160
|
+
claudeSessionId: sessionId,
|
|
161
|
+
model,
|
|
162
|
+
capturedAt: new Date(),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function numberOrZero(v) {
|
|
166
|
+
return typeof v === 'number' && isFinite(v) ? v : 0;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Find the most recently modified `rollout-*.jsonl` under
|
|
170
|
+
* `~/.codex/sessions/YYYY/MM/DD/`. Returns null if the directory does not
|
|
171
|
+
* exist or is empty.
|
|
172
|
+
*/
|
|
173
|
+
function findLatestSessionFile(sessionsRoot) {
|
|
174
|
+
try {
|
|
175
|
+
const root = sessionsRoot ?? path.join(os.homedir(), '.codex', 'sessions');
|
|
176
|
+
if (!fs.existsSync(root))
|
|
177
|
+
return null;
|
|
178
|
+
let latestPath = null;
|
|
179
|
+
let latestMtime = 0;
|
|
180
|
+
// Walk YYYY / MM / DD / rollout-*.jsonl. Bounded to three levels so
|
|
181
|
+
// a stray symlink can't blow up the scan.
|
|
182
|
+
const years = safeReaddir(root);
|
|
183
|
+
for (const year of years) {
|
|
184
|
+
const yearDir = path.join(root, year);
|
|
185
|
+
if (!isDirectory(yearDir))
|
|
186
|
+
continue;
|
|
187
|
+
for (const month of safeReaddir(yearDir)) {
|
|
188
|
+
const monthDir = path.join(yearDir, month);
|
|
189
|
+
if (!isDirectory(monthDir))
|
|
190
|
+
continue;
|
|
191
|
+
for (const day of safeReaddir(monthDir)) {
|
|
192
|
+
const dayDir = path.join(monthDir, day);
|
|
193
|
+
if (!isDirectory(dayDir))
|
|
194
|
+
continue;
|
|
195
|
+
for (const entry of safeReaddir(dayDir)) {
|
|
196
|
+
if (!entry.endsWith('.jsonl'))
|
|
197
|
+
continue;
|
|
198
|
+
const full = path.join(dayDir, entry);
|
|
199
|
+
try {
|
|
200
|
+
const stat = fs.statSync(full);
|
|
201
|
+
if (stat.mtimeMs > latestMtime) {
|
|
202
|
+
latestMtime = stat.mtimeMs;
|
|
203
|
+
latestPath = full;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch { /* skip unreadable */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return latestPath;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function safeReaddir(dir) {
|
|
218
|
+
try {
|
|
219
|
+
return fs.readdirSync(dir);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function isDirectory(p) {
|
|
226
|
+
try {
|
|
227
|
+
return fs.statSync(p).isDirectory();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -222,12 +222,19 @@ function readFrontmatter(content) {
|
|
|
222
222
|
}
|
|
223
223
|
return frontmatter;
|
|
224
224
|
}
|
|
225
|
+
function isRetrospectiveSynthesized(value) {
|
|
226
|
+
if (value === undefined)
|
|
227
|
+
return false;
|
|
228
|
+
const normalized = value.trim().toLowerCase();
|
|
229
|
+
if (!normalized)
|
|
230
|
+
return false;
|
|
231
|
+
return normalized !== 'false' && normalized !== 'no' && normalized !== '0' && normalized !== 'null';
|
|
232
|
+
}
|
|
225
233
|
function isUnsynthesizedRetrospective(filePath) {
|
|
226
234
|
try {
|
|
227
235
|
const content = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
228
236
|
const frontmatter = readFrontmatter(content);
|
|
229
|
-
|
|
230
|
-
return synthesized !== 'true' && synthesized !== 'yes';
|
|
237
|
+
return !isRetrospectiveSynthesized(frontmatter.synthesized);
|
|
231
238
|
}
|
|
232
239
|
catch {
|
|
233
240
|
return false;
|
|
@@ -340,7 +347,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
340
347
|
if (l2PrefPresent)
|
|
341
348
|
section += `\`${LEARNINGS_REL}/org-preferences.md\` (all entries)\n`;
|
|
342
349
|
if (l2CoachPresent)
|
|
343
|
-
section += `\`${LEARNINGS_REL}/org-manager-coaching.md\` (all entries)\n`;
|
|
350
|
+
section += `\`${LEARNINGS_REL}/org-manager-coaching.md\` (manager-facing; all entries)\n`;
|
|
344
351
|
if (l2ValidatedPresent)
|
|
345
352
|
section += `\`${LEARNINGS_REL}/org-validated-patterns.md\` (entries above score threshold)\n`;
|
|
346
353
|
const l2DormantTotal = (l2MistakeStats?.dormant || 0) + (l2ValidatedStats?.dormant || 0);
|
|
@@ -354,7 +361,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
354
361
|
if (l1PrefPresent)
|
|
355
362
|
section += `\`${LEARNINGS_REL}/${resolvedUserId}-preferences.md\` (all entries)\n`;
|
|
356
363
|
if (l1CoachPresent)
|
|
357
|
-
section += `\`${LEARNINGS_REL}/${resolvedUserId}-manager-coaching.md\` (all entries)\n`;
|
|
364
|
+
section += `\`${LEARNINGS_REL}/${resolvedUserId}-manager-coaching.md\` (manager-facing; all entries)\n`;
|
|
358
365
|
if (l1MistakePresent)
|
|
359
366
|
section += `\`${LEARNINGS_REL}/${resolvedUserId}-mistake-patterns.md\` (entries above score threshold)\n`;
|
|
360
367
|
if (l1ValidatedPresent)
|
|
@@ -371,7 +378,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
371
378
|
section += `${l0CoachingCount} coaching moment${l0CoachingCount !== 1 ? 's' : ''} in \`${LEARNINGS_REL}/raw/${resolvedUserId}-*\`\n`;
|
|
372
379
|
}
|
|
373
380
|
if (l0RetroCount > 0) {
|
|
374
|
-
section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''} in \`docs/retrospectives/${resolvedUserId}-*\` with \`synthesized
|
|
381
|
+
section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''} in \`docs/retrospectives/${resolvedUserId}-*\` with unsynthesized or missing \`synthesized\` frontmatter\n`;
|
|
375
382
|
}
|
|
376
383
|
section += '\n';
|
|
377
384
|
}
|
|
@@ -381,20 +388,26 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
381
388
|
const backlogTriggered = totalL0 >= BACKLOG_MIN || (oldestAgeDays >= OLDEST_AGE_DAYS_TRIGGER && totalL0 > 0);
|
|
382
389
|
if (forJob) {
|
|
383
390
|
if (hasL2 || hasL1) {
|
|
384
|
-
section += 'Use the relevant patterns
|
|
391
|
+
section += 'Use the relevant patterns and preferences in this job.\n';
|
|
392
|
+
if (l1CoachPresent || l2CoachPresent) {
|
|
393
|
+
section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
|
|
394
|
+
}
|
|
385
395
|
}
|
|
386
396
|
if (backlogTriggered) {
|
|
387
397
|
section += '\n';
|
|
388
|
-
section += `Warning: ${totalL0} unprocessed signals pending. Consider running \`
|
|
398
|
+
section += `Warning: ${totalL0} unprocessed signals pending. Consider running \`sleep-on-learnings\` before starting today's work.\n`;
|
|
389
399
|
section += renderBacklogDetail(oldestAgeDays, agingRisk);
|
|
390
400
|
}
|
|
391
401
|
}
|
|
392
402
|
else {
|
|
393
403
|
section += 'Use this synthesized learning context throughout the session.\n';
|
|
404
|
+
if (l1CoachPresent || l2CoachPresent) {
|
|
405
|
+
section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
|
|
406
|
+
}
|
|
394
407
|
if (backlogTriggered) {
|
|
395
408
|
section += '\n';
|
|
396
409
|
section += `Warning: synthesis overdue with ${totalL0} unprocessed signals.\n`;
|
|
397
|
-
section += 'Run `
|
|
410
|
+
section += 'Run `sleep-on-learnings` before starting today\'s work.\n';
|
|
398
411
|
section += renderBacklogDetail(oldestAgeDays, agingRisk);
|
|
399
412
|
}
|
|
400
413
|
}
|
|
@@ -33,6 +33,7 @@ const quality_evidence_1 = require("../core/quality-evidence");
|
|
|
33
33
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
34
34
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
35
35
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
36
|
+
const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
|
|
36
37
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
37
38
|
/**
|
|
38
39
|
* Handle template substitution logic separately for better testability
|
|
@@ -617,9 +618,10 @@ class FraimLocalMCPServer {
|
|
|
617
618
|
// Fall back to config if git fails
|
|
618
619
|
repoUrl = this.config?.repository?.url || '';
|
|
619
620
|
}
|
|
621
|
+
const workspaceLabel = (0, path_1.parse)(projectDir).base || 'workspace';
|
|
620
622
|
if (!repoUrl) {
|
|
621
|
-
this.log(
|
|
622
|
-
|
|
623
|
+
this.log(`No git repository found; falling back to workspace folder label: ${workspaceLabel}`);
|
|
624
|
+
repoUrl = workspaceLabel;
|
|
623
625
|
}
|
|
624
626
|
// Parse repository identity from URL
|
|
625
627
|
let name = '';
|
|
@@ -646,6 +648,9 @@ class FraimLocalMCPServer {
|
|
|
646
648
|
name = segments[segments.length - 1] || '';
|
|
647
649
|
namespace = segments.slice(0, -1).join('/');
|
|
648
650
|
}
|
|
651
|
+
else if (!repoUrl.includes('/') && !repoUrl.includes('\\') && !repoUrl.includes('://')) {
|
|
652
|
+
name = repoUrl;
|
|
653
|
+
}
|
|
649
654
|
else if (this.config?.repository) {
|
|
650
655
|
// Fall back to config if URL parsing fails
|
|
651
656
|
owner = this.config.repository.owner || '';
|
|
@@ -1503,6 +1508,11 @@ class FraimLocalMCPServer {
|
|
|
1503
1508
|
this.engine.setMachineInfo(this.machineInfo);
|
|
1504
1509
|
this.engine.setRepoInfo(this.repoInfo);
|
|
1505
1510
|
}
|
|
1511
|
+
// Issue #330: capture agent identity on the usage collector so every
|
|
1512
|
+
// queued event is attributed without re-reading args downstream.
|
|
1513
|
+
if (this.usageCollector && this.agentInfo) {
|
|
1514
|
+
this.usageCollector.setAgent(this.agentInfo.name ?? null, this.agentInfo.model ?? null);
|
|
1515
|
+
}
|
|
1506
1516
|
// In a proxy setup, the remote server resolves the API key ID during event upload.
|
|
1507
1517
|
// No local resolution needed.
|
|
1508
1518
|
// Update the request with injected info
|
|
@@ -1989,17 +1999,28 @@ class FraimLocalMCPServer {
|
|
|
1989
1999
|
const afterCount = this.usageCollector.getEventCount();
|
|
1990
2000
|
if (afterCount > beforeCount) {
|
|
1991
2001
|
this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
|
|
1992
|
-
//
|
|
2002
|
+
// Issue #330: pluggable token capture by agent.
|
|
1993
2003
|
if (toolName === 'seekMentoring') {
|
|
1994
2004
|
try {
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2005
|
+
const agentName = this.agentInfo?.name;
|
|
2006
|
+
const agentModel = this.agentInfo?.model;
|
|
2007
|
+
const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
|
|
2008
|
+
const result = await adapter.capture({
|
|
2009
|
+
agent: agentName,
|
|
2010
|
+
model: agentModel,
|
|
2011
|
+
log: (msg) => this.log(`📊 ${msg}`),
|
|
2012
|
+
});
|
|
2013
|
+
if (result.snapshot) {
|
|
2014
|
+
this.usageCollector.attachTokenSnapshot(result.snapshot);
|
|
2015
|
+
this.log(`📊 🔢 Token snapshot attached via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
|
|
2016
|
+
}
|
|
2017
|
+
else if (result.reason) {
|
|
2018
|
+
this.usageCollector.attachCaptureUnavailable(result.reason);
|
|
2019
|
+
this.log(`📊 ℹ️ Token capture unavailable (${result.reason}) for adapter ${adapter.id}`);
|
|
1999
2020
|
}
|
|
2000
2021
|
}
|
|
2001
2022
|
catch (err) {
|
|
2002
|
-
this.log(`📊 ⚠️ Token snapshot
|
|
2023
|
+
this.log(`📊 ⚠️ Token snapshot capture failed (non-blocking): ${err.message}`);
|
|
2003
2024
|
}
|
|
2004
2025
|
}
|
|
2005
2026
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Token Adapter Registry — Issue #330 / R1.1, R1.4, R1.5
|
|
4
|
+
*
|
|
5
|
+
* Pluggable dispatch by agent name. Each adapter implements
|
|
6
|
+
* `capture(ctx)` returning either a `TokenSnapshot` or `null` plus a
|
|
7
|
+
* typed reason (`tokenCaptureUnavailableReason`) so the UI can explain
|
|
8
|
+
* coverage gaps.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.resolveTokenAdapter = resolveTokenAdapter;
|
|
12
|
+
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
13
|
+
const codex_token_adapter_js_1 = require("./codex-token-adapter.js");
|
|
14
|
+
const claudeCodeAdapter = {
|
|
15
|
+
id: 'claude-code',
|
|
16
|
+
async capture(ctx) {
|
|
17
|
+
const snap = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(ctx.agentSessionId, ctx.log);
|
|
18
|
+
if (snap)
|
|
19
|
+
return { snapshot: snap };
|
|
20
|
+
return { snapshot: null, reason: 'otlp_receiver_unreachable' };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const codexAdapter = {
|
|
24
|
+
id: 'codex',
|
|
25
|
+
async capture(ctx) {
|
|
26
|
+
try {
|
|
27
|
+
const snap = await (0, codex_token_adapter_js_1.captureCodexSnapshot)({ log: ctx.log });
|
|
28
|
+
if (snap)
|
|
29
|
+
return { snapshot: snap };
|
|
30
|
+
return { snapshot: null, reason: 'codex_session_log_not_found' };
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
ctx.log?.(`codex adapter threw: ${err.message}`);
|
|
34
|
+
return { snapshot: null, reason: 'codex_adapter_error' };
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const nullAdapterFor = (canonicalAgent) => ({
|
|
39
|
+
id: 'null',
|
|
40
|
+
async capture() {
|
|
41
|
+
return { snapshot: null, reason: `agent_not_supported:${canonicalAgent}` };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Map of canonical agent ids to adapters. Keys are lower-cased.
|
|
46
|
+
* Aliases (e.g. "claude" → claude-code) are handled in the resolver below.
|
|
47
|
+
*/
|
|
48
|
+
const ADAPTERS = {
|
|
49
|
+
'claude-code': claudeCodeAdapter,
|
|
50
|
+
'claude': claudeCodeAdapter,
|
|
51
|
+
'codex': codexAdapter,
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Resolve an adapter by agent.name (case-insensitive). Always returns
|
|
55
|
+
* an adapter — falls back to a null adapter that reports
|
|
56
|
+
* "agent_not_supported:<name>".
|
|
57
|
+
*/
|
|
58
|
+
function resolveTokenAdapter(agentName) {
|
|
59
|
+
const canonical = (agentName || 'unknown').toLowerCase().trim();
|
|
60
|
+
const adapter = ADAPTERS[canonical];
|
|
61
|
+
if (adapter)
|
|
62
|
+
return adapter;
|
|
63
|
+
return nullAdapterFor(canonical || 'unknown');
|
|
64
|
+
}
|
|
@@ -14,6 +14,9 @@ class UsageCollector {
|
|
|
14
14
|
this.events = [];
|
|
15
15
|
this.userId = null;
|
|
16
16
|
this.repoIdentifier = null;
|
|
17
|
+
// Issue #330: per-session agent attribution captured at fraim_connect.
|
|
18
|
+
this.agentName = null;
|
|
19
|
+
this.agentModel = null;
|
|
17
20
|
}
|
|
18
21
|
static resolveMentoringJobName(args) {
|
|
19
22
|
if (!args || typeof args !== 'object') {
|
|
@@ -40,6 +43,16 @@ class UsageCollector {
|
|
|
40
43
|
setRepoIdentifier(repoIdentifier) {
|
|
41
44
|
this.repoIdentifier = repoIdentifier;
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Capture the agent identity at fraim_connect time so subsequent events
|
|
48
|
+
* can be attributed to the right agent without re-reading args.
|
|
49
|
+
*/
|
|
50
|
+
setAgent(name, model) {
|
|
51
|
+
this.agentName = name ? name.toLowerCase() : null;
|
|
52
|
+
this.agentModel = model || null;
|
|
53
|
+
}
|
|
54
|
+
getAgentName() { return this.agentName; }
|
|
55
|
+
getAgentModel() { return this.agentModel; }
|
|
43
56
|
/**
|
|
44
57
|
* Collect MCP tool call event
|
|
45
58
|
*/
|
|
@@ -67,6 +80,8 @@ class UsageCollector {
|
|
|
67
80
|
success,
|
|
68
81
|
category: parsed.category,
|
|
69
82
|
repoIdentifier: this.repoIdentifier || undefined,
|
|
83
|
+
agentName: this.agentName || undefined,
|
|
84
|
+
agentModel: this.agentModel || undefined,
|
|
70
85
|
args: Object.keys(analyticsArgs).length > 0 ? analyticsArgs : undefined
|
|
71
86
|
};
|
|
72
87
|
this.events.push(event);
|
|
@@ -178,6 +193,16 @@ class UsageCollector {
|
|
|
178
193
|
this.events[this.events.length - 1].tokenSnapshot = snapshot;
|
|
179
194
|
}
|
|
180
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Issue #330: when a token capture adapter returns null, record the
|
|
198
|
+
* typed reason on the most recently queued event so the dashboard
|
|
199
|
+
* can explain coverage gaps to the user.
|
|
200
|
+
*/
|
|
201
|
+
attachCaptureUnavailable(reason) {
|
|
202
|
+
if (this.events.length > 0) {
|
|
203
|
+
this.events[this.events.length - 1].tokenCaptureUnavailableReason = reason;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
181
206
|
/**
|
|
182
207
|
* Get current event count
|
|
183
208
|
*/
|
package/index.js
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* FRAIM Framework - Smart Entry Point
|
|
5
|
-
* This file handles both production (dist/) and development (src/) environments.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const { spawnSync } = require('child_process');
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Runs the CLI using either the compiled JS or the source TS via tsx
|
|
14
|
-
*/
|
|
15
|
-
function runCLI() {
|
|
16
|
-
const distPath = path.join(__dirname, 'dist', 'src', 'cli', 'fraim.js');
|
|
17
|
-
const srcPath = path.join(__dirname, 'src', 'cli', 'fraim.ts');
|
|
18
|
-
|
|
19
|
-
// 1. Check if we have a compiled version (Production / CI)
|
|
20
|
-
if (fs.existsSync(distPath)) {
|
|
21
|
-
require(distPath);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// 2. Explicitly fail in production if dist/ is missing
|
|
26
|
-
if (process.env.NODE_ENV === 'production') {
|
|
27
|
-
console.error('❌ FRAIM Error: Production build (dist/) not found.');
|
|
28
|
-
console.error('In production environments, you must run the compiled version.');
|
|
29
|
-
console.error(`Expected: ${distPath}`);
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// 3. Fallback to source version using tsx (Development)
|
|
34
|
-
if (fs.existsSync(srcPath)) {
|
|
35
|
-
// We use spawnSync to run tsx so we don't have to require it in memory
|
|
36
|
-
// if it's not needed, and it handles the process arguments correctly.
|
|
37
|
-
//
|
|
38
|
-
// IMPORTANT FIX: Directory names with spaces and dashes (e.g., "FRAIM - Issue 166")
|
|
39
|
-
// cause argument parsing issues on Windows when shell: true is used.
|
|
40
|
-
// Without quoting, a path like "C:\...\FRAIM - Issue 166\src\cli\fraim.ts" gets
|
|
41
|
-
// split into multiple arguments, with the dash interpreted as a command flag,
|
|
42
|
-
// resulting in "error: unknown command '-'".
|
|
43
|
-
//
|
|
44
|
-
// Solution: On Windows with shell: true, quote paths containing spaces.
|
|
45
|
-
// On Unix with shell: false, pass the path unquoted (spawnSync handles it correctly).
|
|
46
|
-
const isWindows = process.platform === 'win32';
|
|
47
|
-
|
|
48
|
-
// On Windows with shell, quote paths with spaces to prevent shell misinterpretation
|
|
49
|
-
// On Unix without shell, pass path as-is (spawnSync handles spaces correctly)
|
|
50
|
-
const processedSrcPath = (isWindows && srcPath.includes(' '))
|
|
51
|
-
? `"${srcPath}"`
|
|
52
|
-
: srcPath;
|
|
53
|
-
|
|
54
|
-
const result = spawnSync(
|
|
55
|
-
'npx',
|
|
56
|
-
['tsx', processedSrcPath, ...process.argv.slice(2)],
|
|
57
|
-
{
|
|
58
|
-
stdio: 'inherit',
|
|
59
|
-
shell: isWindows, // Windows needs shell for npx, Unix doesn't
|
|
60
|
-
windowsHide: true
|
|
61
|
-
}
|
|
62
|
-
);
|
|
63
|
-
process.exit(result.status || 0);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
console.error('❌ FRAIM Error: Could not find CLI entry point.');
|
|
67
|
-
console.error('Expected one of:');
|
|
68
|
-
console.error(` - ${distPath}`);
|
|
69
|
-
console.error(` - ${srcPath}`);
|
|
70
|
-
process.exit(1);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Global programmatic exports
|
|
74
|
-
module.exports = {
|
|
75
|
-
FRAIM_INFO: {
|
|
76
|
-
name: 'FRAIM',
|
|
77
|
-
version: '2.0.98',
|
|
78
|
-
repository: 'https://github.com/mathursrus/FRAIM'
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
// If this file is run directly (via npx or global link), run the CLI
|
|
83
|
-
if (require.main === module) {
|
|
84
|
-
runCLI();
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FRAIM Framework - Smart Entry Point
|
|
5
|
+
* This file handles both production (dist/) and development (src/) environments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs the CLI using either the compiled JS or the source TS via tsx
|
|
14
|
+
*/
|
|
15
|
+
function runCLI() {
|
|
16
|
+
const distPath = path.join(__dirname, 'dist', 'src', 'cli', 'fraim.js');
|
|
17
|
+
const srcPath = path.join(__dirname, 'src', 'cli', 'fraim.ts');
|
|
18
|
+
|
|
19
|
+
// 1. Check if we have a compiled version (Production / CI)
|
|
20
|
+
if (fs.existsSync(distPath)) {
|
|
21
|
+
require(distPath);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Explicitly fail in production if dist/ is missing
|
|
26
|
+
if (process.env.NODE_ENV === 'production') {
|
|
27
|
+
console.error('❌ FRAIM Error: Production build (dist/) not found.');
|
|
28
|
+
console.error('In production environments, you must run the compiled version.');
|
|
29
|
+
console.error(`Expected: ${distPath}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Fallback to source version using tsx (Development)
|
|
34
|
+
if (fs.existsSync(srcPath)) {
|
|
35
|
+
// We use spawnSync to run tsx so we don't have to require it in memory
|
|
36
|
+
// if it's not needed, and it handles the process arguments correctly.
|
|
37
|
+
//
|
|
38
|
+
// IMPORTANT FIX: Directory names with spaces and dashes (e.g., "FRAIM - Issue 166")
|
|
39
|
+
// cause argument parsing issues on Windows when shell: true is used.
|
|
40
|
+
// Without quoting, a path like "C:\...\FRAIM - Issue 166\src\cli\fraim.ts" gets
|
|
41
|
+
// split into multiple arguments, with the dash interpreted as a command flag,
|
|
42
|
+
// resulting in "error: unknown command '-'".
|
|
43
|
+
//
|
|
44
|
+
// Solution: On Windows with shell: true, quote paths containing spaces.
|
|
45
|
+
// On Unix with shell: false, pass the path unquoted (spawnSync handles it correctly).
|
|
46
|
+
const isWindows = process.platform === 'win32';
|
|
47
|
+
|
|
48
|
+
// On Windows with shell, quote paths with spaces to prevent shell misinterpretation
|
|
49
|
+
// On Unix without shell, pass path as-is (spawnSync handles spaces correctly)
|
|
50
|
+
const processedSrcPath = (isWindows && srcPath.includes(' '))
|
|
51
|
+
? `"${srcPath}"`
|
|
52
|
+
: srcPath;
|
|
53
|
+
|
|
54
|
+
const result = spawnSync(
|
|
55
|
+
'npx',
|
|
56
|
+
['tsx', processedSrcPath, ...process.argv.slice(2)],
|
|
57
|
+
{
|
|
58
|
+
stdio: 'inherit',
|
|
59
|
+
shell: isWindows, // Windows needs shell for npx, Unix doesn't
|
|
60
|
+
windowsHide: true
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
process.exit(result.status || 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.error('❌ FRAIM Error: Could not find CLI entry point.');
|
|
67
|
+
console.error('Expected one of:');
|
|
68
|
+
console.error(` - ${distPath}`);
|
|
69
|
+
console.error(` - ${srcPath}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Global programmatic exports
|
|
74
|
+
module.exports = {
|
|
75
|
+
FRAIM_INFO: {
|
|
76
|
+
name: 'FRAIM',
|
|
77
|
+
version: '2.0.98',
|
|
78
|
+
repository: 'https://github.com/mathursrus/FRAIM'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// If this file is run directly (via npx or global link), run the CLI
|
|
83
|
+
if (require.main === module) {
|
|
84
|
+
runCLI();
|
|
85
85
|
}
|