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.
- package/bin/fraim.js +1 -1
- package/dist/src/ai-hub/catalog.js +280 -44
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/ai-hub/hosts.js +384 -10
- package/dist/src/ai-hub/server.js +255 -9
- package/dist/src/cli/commands/add-ide.js +4 -3
- package/dist/src/cli/commands/first-run.js +61 -0
- package/dist/src/cli/commands/hub.js +4 -4
- package/dist/src/cli/commands/init-project.js +4 -4
- package/dist/src/cli/commands/setup.js +4 -3
- package/dist/src/cli/commands/sync.js +21 -2
- package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +29 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
- package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
- package/dist/src/cli/setup/ide-detector.js +32 -1
- package/dist/src/cli/setup/ide-global-integration.js +5 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +70 -17
- package/dist/src/cli/setup/mcp-config-generator.js +12 -1
- package/dist/src/cli/utils/agent-adapters.js +12 -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 +32 -7
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/workflow-parser.js +3 -5
- package/dist/src/first-run/install-state.js +68 -0
- package/dist/src/first-run/server.js +153 -0
- package/dist/src/first-run/session-service.js +302 -0
- package/dist/src/first-run/types.js +40 -0
- 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/otlp-metrics-receiver.js +7 -1
- package/dist/src/local-mcp-server/stdio-server.js +70 -17
- 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 +7 -1
- package/public/ai-hub/index.html +149 -102
- package/public/ai-hub/script.js +1154 -271
- package/public/ai-hub/styles.css +753 -450
- package/public/first-run/index.html +221 -0
- package/public/first-run/script.js +361 -0
- 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,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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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(
|
|
622
|
-
|
|
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.
|
|
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
|
-
//
|
|
2034
|
+
// Issue #330: pluggable token capture by agent.
|
|
1993
2035
|
if (toolName === 'seekMentoring') {
|
|
1994
2036
|
try {
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
|
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
|
+
}
|