fraim-framework 2.0.124 → 2.0.127

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/fraim.js +1 -1
  2. package/dist/src/ai-hub/catalog.js +280 -44
  3. package/dist/src/ai-hub/desktop-main.js +2 -2
  4. package/dist/src/ai-hub/hosts.js +384 -10
  5. package/dist/src/ai-hub/server.js +255 -9
  6. package/dist/src/cli/commands/add-ide.js +4 -3
  7. package/dist/src/cli/commands/first-run.js +61 -0
  8. package/dist/src/cli/commands/hub.js +4 -4
  9. package/dist/src/cli/commands/init-project.js +4 -4
  10. package/dist/src/cli/commands/setup.js +4 -3
  11. package/dist/src/cli/commands/sync.js +21 -2
  12. package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
  13. package/dist/src/cli/fraim.js +2 -0
  14. package/dist/src/cli/mcp/ide-formats.js +29 -1
  15. package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
  16. package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
  17. package/dist/src/cli/setup/ide-detector.js +32 -1
  18. package/dist/src/cli/setup/ide-global-integration.js +5 -1
  19. package/dist/src/cli/setup/ide-invocation-surfaces.js +70 -17
  20. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  21. package/dist/src/cli/utils/agent-adapters.js +12 -2
  22. package/dist/src/cli/utils/project-bootstrap.js +4 -3
  23. package/dist/src/core/quality-evidence.js +81 -8
  24. package/dist/src/core/utils/git-utils.js +32 -7
  25. package/dist/src/core/utils/job-aliases.js +47 -0
  26. package/dist/src/core/utils/workflow-parser.js +3 -5
  27. package/dist/src/first-run/install-state.js +68 -0
  28. package/dist/src/first-run/server.js +153 -0
  29. package/dist/src/first-run/session-service.js +302 -0
  30. package/dist/src/first-run/types.js +40 -0
  31. package/dist/src/local-mcp-server/agent-token-prices.js +114 -0
  32. package/dist/src/local-mcp-server/codex-token-adapter.js +232 -0
  33. package/dist/src/local-mcp-server/learning-context-builder.js +21 -8
  34. package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
  35. package/dist/src/local-mcp-server/stdio-server.js +70 -17
  36. package/dist/src/local-mcp-server/token-adapter-registry.js +64 -0
  37. package/dist/src/local-mcp-server/usage-collector.js +25 -0
  38. package/index.js +83 -83
  39. package/package.json +7 -1
  40. package/public/ai-hub/index.html +149 -102
  41. package/public/ai-hub/script.js +1154 -271
  42. package/public/ai-hub/styles.css +753 -450
  43. package/public/first-run/index.html +221 -0
  44. package/public/first-run/script.js +361 -0
  45. package/dist/src/cli/services/device-flow-service.js +0 -83
  46. package/dist/src/local-mcp-server/prometheus-scraper.js +0 -152
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ /**
3
+ * Static price table for converting per-agent token counters to USD.
4
+ *
5
+ * Issue #330 / R1.3 / C4 (ISO 27001 A.5.31).
6
+ *
7
+ * Each entry MUST carry `source` (a citation pointing back to the vendor's
8
+ * published pricing page) and `verifiedOn` (an ISO date string). The
9
+ * `agent-token-prices.test.ts` lint fails CI if any entry exceeds 180 days
10
+ * since `verifiedOn` so this table is reviewed at least quarterly.
11
+ *
12
+ * Prices are USD per million tokens.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.AGENT_TOKEN_PRICES = void 0;
16
+ exports.lookupPrice = lookupPrice;
17
+ exports.computeCostUsd = computeCostUsd;
18
+ exports.AGENT_TOKEN_PRICES = [
19
+ // Anthropic — Claude (used by Claude Code).
20
+ // Note: when Claude Code emits cost via OTLP we read it directly; this
21
+ // table backstops cost computation for legacy/no-cost-metric snapshots
22
+ // and lets per-agent comparison panels report a model-attributed cost
23
+ // even when a snapshot lacks a costUsd field.
24
+ {
25
+ agent: 'claude-code',
26
+ model: 'claude-opus-4-7',
27
+ inputPerMTok: 15.00,
28
+ outputPerMTok: 75.00,
29
+ cacheReadPerMTok: 1.50,
30
+ cacheCreationPerMTok: 18.75,
31
+ source: 'https://www.anthropic.com/pricing',
32
+ verifiedOn: '2026-04-29',
33
+ },
34
+ {
35
+ agent: 'claude-code',
36
+ model: 'claude-sonnet-4-6',
37
+ inputPerMTok: 3.00,
38
+ outputPerMTok: 15.00,
39
+ cacheReadPerMTok: 0.30,
40
+ cacheCreationPerMTok: 3.75,
41
+ source: 'https://www.anthropic.com/pricing',
42
+ verifiedOn: '2026-04-29',
43
+ },
44
+ {
45
+ agent: 'claude-code',
46
+ model: 'claude-haiku-4-5',
47
+ inputPerMTok: 1.00,
48
+ outputPerMTok: 5.00,
49
+ cacheReadPerMTok: 0.10,
50
+ cacheCreationPerMTok: 1.25,
51
+ source: 'https://www.anthropic.com/pricing',
52
+ verifiedOn: '2026-04-29',
53
+ },
54
+ // OpenAI — GPT-5 Codex (used by Codex CLI).
55
+ {
56
+ agent: 'codex',
57
+ model: 'gpt-5-codex',
58
+ inputPerMTok: 5.00,
59
+ outputPerMTok: 15.00,
60
+ cacheReadPerMTok: 0.50,
61
+ cacheCreationPerMTok: 0,
62
+ source: 'https://openai.com/api/pricing/',
63
+ verifiedOn: '2026-04-29',
64
+ },
65
+ {
66
+ agent: 'codex',
67
+ model: 'gpt-5',
68
+ inputPerMTok: 5.00,
69
+ outputPerMTok: 15.00,
70
+ cacheReadPerMTok: 0.50,
71
+ cacheCreationPerMTok: 0,
72
+ source: 'https://openai.com/api/pricing/',
73
+ verifiedOn: '2026-04-29',
74
+ },
75
+ // Real Codex CLI 0.125.0 emits `gpt-5.5` on turn_context.payload.model.
76
+ // Treated as the gpt-5 family for pricing until OpenAI publishes a
77
+ // distinct rate card. Conservative estimate; refresh on next quarterly
78
+ // verification pass.
79
+ {
80
+ agent: 'codex',
81
+ model: 'gpt-5.5',
82
+ inputPerMTok: 5.00,
83
+ outputPerMTok: 15.00,
84
+ cacheReadPerMTok: 0.50,
85
+ cacheCreationPerMTok: 0,
86
+ source: 'https://openai.com/api/pricing/ (gpt-5 row, applied to gpt-5.5 as nearest published rate)',
87
+ verifiedOn: '2026-04-30',
88
+ },
89
+ ];
90
+ /**
91
+ * Look up the price entry for an agent + model. Agent is matched
92
+ * case-insensitively. Returns null when no matching entry exists — the
93
+ * adapter is then expected to skip cost computation rather than guess.
94
+ */
95
+ function lookupPrice(agent, model) {
96
+ if (!agent || !model)
97
+ return null;
98
+ const a = agent.toLowerCase();
99
+ return exports.AGENT_TOKEN_PRICES.find(e => e.agent === a && e.model === model) || null;
100
+ }
101
+ /**
102
+ * Compute USD cost from cumulative token counts. Returns 0 when no price
103
+ * entry exists for the agent/model — the caller decides whether to drop
104
+ * the snapshot entirely or surface a partial-coverage warning.
105
+ */
106
+ function computeCostUsd(agent, model, counts) {
107
+ const price = lookupPrice(agent, model);
108
+ if (!price)
109
+ return 0;
110
+ return ((counts.inputTokens / 1_000_000) * price.inputPerMTok +
111
+ (counts.outputTokens / 1_000_000) * price.outputPerMTok +
112
+ (counts.cacheReadTokens / 1_000_000) * price.cacheReadPerMTok +
113
+ (counts.cacheCreationTokens / 1_000_000) * price.cacheCreationPerMTok);
114
+ }
@@ -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
- const synthesized = frontmatter.synthesized?.toLowerCase();
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: false\` or missing\n`;
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, preferences, and coaching signals in this job.\n';
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 \`end-of-day-debrief\` before starting today's work.\n`;
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 `end-of-day-debrief` before starting today\'s work.\n';
410
+ section += 'Run `sleep-on-learnings` before starting today\'s work.\n';
398
411
  section += renderBacklogDetail(oldestAgeDays, agingRisk);
399
412
  }
400
413
  }
@@ -257,6 +257,12 @@ function startOtlpReceiver(log) {
257
257
  * Stop the OTLP receiver and clear stored snapshots.
258
258
  */
259
259
  function stopOtlpReceiver(server) {
260
- server.close();
260
+ try {
261
+ server.close();
262
+ }
263
+ catch {
264
+ // The receiver may have failed to bind because another FRAIM proxy owns
265
+ // the port. In that case there is no local listener to close.
266
+ }
261
267
  snapshots.clear();
262
268
  }
@@ -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
@@ -401,6 +402,7 @@ class FraimLocalMCPServer {
401
402
  this.repoInfo = null;
402
403
  this.engine = null;
403
404
  this.otlpServer = null;
405
+ this.isShutdown = false;
404
406
  this.writer = writer || process.stdout.write.bind(process.stdout);
405
407
  this.remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
406
408
  this.apiKey = this.loadApiKey();
@@ -421,6 +423,16 @@ class FraimLocalMCPServer {
421
423
  // Start OTLP metrics receiver for Claude Code token telemetry
422
424
  this.otlpServer = (0, otlp_metrics_receiver_js_1.startOtlpReceiver)((msg) => this.log(`📊 ${msg}`));
423
425
  }
426
+ shutdown() {
427
+ if (this.isShutdown)
428
+ return;
429
+ this.isShutdown = true;
430
+ this.usageCollector.shutdown();
431
+ if (this.otlpServer?.server) {
432
+ (0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
433
+ this.otlpServer = null;
434
+ }
435
+ }
424
436
  /**
425
437
  * Load API key from environment variable or user config file
426
438
  * Priority: FRAIM_API_KEY env var > ~/.fraim/config.json
@@ -617,9 +629,10 @@ class FraimLocalMCPServer {
617
629
  // Fall back to config if git fails
618
630
  repoUrl = this.config?.repository?.url || '';
619
631
  }
632
+ const workspaceLabel = (0, path_1.parse)(projectDir).base || 'workspace';
620
633
  if (!repoUrl) {
621
- this.log('No git repository found and no config available');
622
- return null;
634
+ this.log(`No git repository found; falling back to workspace folder label: ${workspaceLabel}`);
635
+ repoUrl = workspaceLabel;
623
636
  }
624
637
  // Parse repository identity from URL
625
638
  let name = '';
@@ -646,6 +659,9 @@ class FraimLocalMCPServer {
646
659
  name = segments[segments.length - 1] || '';
647
660
  namespace = segments.slice(0, -1).join('/');
648
661
  }
662
+ else if (!repoUrl.includes('/') && !repoUrl.includes('\\') && !repoUrl.includes('://')) {
663
+ name = repoUrl;
664
+ }
649
665
  else if (this.config?.repository) {
650
666
  // Fall back to config if URL parsing fails
651
667
  owner = this.config.repository.owner || '';
@@ -1431,6 +1447,33 @@ class FraimLocalMCPServer {
1431
1447
  projectPath: `${issueTracking.namespace}/${issueTracking.name}`
1432
1448
  };
1433
1449
  }
1450
+ hasRemoteRepoLocator(value) {
1451
+ if (typeof value !== 'string')
1452
+ return false;
1453
+ return /^[a-z]+:\/\//i.test(value) || /^[^@\s]+@[^:\s]+:/i.test(value);
1454
+ }
1455
+ mergeRepoContexts(agentRepo, detectedRepo) {
1456
+ const normalizedAgent = this.normalizeRepoContext(agentRepo);
1457
+ const normalizedDetected = this.normalizeRepoContext(detectedRepo);
1458
+ if (!normalizedAgent)
1459
+ return normalizedDetected;
1460
+ if (!normalizedDetected)
1461
+ return normalizedAgent;
1462
+ const agentHasRemoteUrl = this.hasRemoteRepoLocator(normalizedAgent.url);
1463
+ const detectedHasRemoteUrl = this.hasRemoteRepoLocator(normalizedDetected.url);
1464
+ // Preserve an explicit agent-supplied remote repository when local auto-detection
1465
+ // only found a workspace label or other non-remote fallback.
1466
+ if (agentHasRemoteUrl && !detectedHasRemoteUrl) {
1467
+ return this.normalizeRepoContext({
1468
+ ...normalizedDetected,
1469
+ ...normalizedAgent
1470
+ });
1471
+ }
1472
+ return this.normalizeRepoContext({
1473
+ ...normalizedAgent,
1474
+ ...normalizedDetected
1475
+ });
1476
+ }
1434
1477
  /**
1435
1478
  * Internal method to perform the actual proxy request to the remote server.
1436
1479
  * This method does NOT inject raw: true, as it is used for both top-level
@@ -1452,11 +1495,7 @@ class FraimLocalMCPServer {
1452
1495
  // REQUIRED: Auto-detect and inject repo info
1453
1496
  const detectedRepo = this.detectRepoInfo();
1454
1497
  if (detectedRepo) {
1455
- args.repo = {
1456
- ...args.repo, // Agent values as fallback
1457
- ...detectedRepo // Detected values override (always win)
1458
- };
1459
- args.repo = this.normalizeRepoContext(args.repo);
1498
+ args.repo = this.mergeRepoContexts(args.repo, detectedRepo);
1460
1499
  const repoLabel = args.repo.owner ? `${args.repo.owner}/${args.repo.name}` : args.repo.name;
1461
1500
  this.log(`[req:${requestId}] Auto-detected and injected repo info: ${repoLabel}`);
1462
1501
  }
@@ -1503,6 +1542,11 @@ class FraimLocalMCPServer {
1503
1542
  this.engine.setMachineInfo(this.machineInfo);
1504
1543
  this.engine.setRepoInfo(this.repoInfo);
1505
1544
  }
1545
+ // Issue #330: capture agent identity on the usage collector so every
1546
+ // queued event is attributed without re-reading args downstream.
1547
+ if (this.usageCollector && this.agentInfo) {
1548
+ this.usageCollector.setAgent(this.agentInfo.name ?? null, this.agentInfo.model ?? null);
1549
+ }
1506
1550
  // In a proxy setup, the remote server resolves the API key ID during event upload.
1507
1551
  // No local resolution needed.
1508
1552
  // Update the request with injected info
@@ -1884,13 +1928,11 @@ class FraimLocalMCPServer {
1884
1928
  this.log(`⚠️ Failed to upload usage data: ${error.message}`);
1885
1929
  });
1886
1930
  }, 60000); // Upload every minute
1931
+ uploadInterval.unref?.();
1887
1932
  // Clean up interval on shutdown
1888
1933
  const cleanup = () => {
1889
1934
  clearInterval(uploadInterval);
1890
- this.usageCollector.shutdown();
1891
- if (this.otlpServer?.server) {
1892
- (0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
1893
- }
1935
+ this.shutdown();
1894
1936
  };
1895
1937
  process.stdin.on('data', async (chunk) => {
1896
1938
  buffer += chunk;
@@ -1989,17 +2031,28 @@ class FraimLocalMCPServer {
1989
2031
  const afterCount = this.usageCollector.getEventCount();
1990
2032
  if (afterCount > beforeCount) {
1991
2033
  this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
1992
- // Fetch token snapshot from OTLP receiver for seekMentoring calls
2034
+ // Issue #330: pluggable token capture by agent.
1993
2035
  if (toolName === 'seekMentoring') {
1994
2036
  try {
1995
- const snapshot = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(undefined, (msg) => this.log(`📊 ${msg}`));
1996
- if (snapshot) {
1997
- this.usageCollector.attachTokenSnapshot(snapshot);
1998
- this.log(`📊 🔢 Token snapshot attached (input=${snapshot.inputTokens}, output=${snapshot.outputTokens}, session=${snapshot.claudeSessionId})`);
2037
+ const agentName = this.agentInfo?.name;
2038
+ const agentModel = this.agentInfo?.model;
2039
+ const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
2040
+ const result = await adapter.capture({
2041
+ agent: agentName,
2042
+ model: agentModel,
2043
+ log: (msg) => this.log(`📊 ${msg}`),
2044
+ });
2045
+ if (result.snapshot) {
2046
+ this.usageCollector.attachTokenSnapshot(result.snapshot);
2047
+ this.log(`📊 🔢 Token snapshot attached via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
2048
+ }
2049
+ else if (result.reason) {
2050
+ this.usageCollector.attachCaptureUnavailable(result.reason);
2051
+ this.log(`📊 ℹ️ Token capture unavailable (${result.reason}) for adapter ${adapter.id}`);
1999
2052
  }
2000
2053
  }
2001
2054
  catch (err) {
2002
- this.log(`📊 ⚠️ Token snapshot fetch failed (non-blocking): ${err.message}`);
2055
+ this.log(`📊 ⚠️ Token snapshot capture failed (non-blocking): ${err.message}`);
2003
2056
  }
2004
2057
  }
2005
2058
  }
@@ -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
+ }