fraim 2.0.270 → 2.0.272
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/commands/add-ide.js +37 -132
- package/dist/src/cli/commands/add-provider.js +32 -268
- package/dist/src/cli/commands/learning-usage.js +412 -0
- package/dist/src/cli/commands/login.js +5 -5
- package/dist/src/cli/commands/setup.js +15 -52
- package/dist/src/cli/commands/sync.js +111 -80
- package/dist/src/cli/fraim.js +1 -42
- package/dist/src/cli/mcp/ide-formats.js +1 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +3 -3
- package/dist/src/cli/providers/local-provider-registry.js +4 -4
- package/dist/src/cli/setup/auto-mcp-setup.js +4 -13
- package/dist/src/cli/utils/remote-sync.js +41 -25
- package/dist/src/core/ai-mentor.js +27 -14
- package/dist/src/core/config-loader.js +48 -3
- package/dist/src/core/fraim-config-schema.generated.js +18 -0
- package/dist/src/core/handoff-contracts.js +37 -1
- package/dist/src/core/job-phases.js +2 -14
- package/dist/src/core/resolve-phase-edge.js +75 -0
- package/dist/src/core/types.js +7 -1
- package/dist/src/core/utils/git-utils.js +24 -14
- package/dist/src/core/utils/project-fraim-paths.js +16 -1
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +8 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +448 -95
- package/dist/src/local-mcp-server/learning-firing-parser.js +247 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +347 -0
- package/dist/src/local-mcp-server/learning-usage-attestation.js +191 -0
- package/dist/src/local-mcp-server/learning-usage-command.js +408 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +79 -0
- package/dist/src/local-mcp-server/learning-usage-store.js +417 -0
- package/dist/src/local-mcp-server/stdio-server.js +43 -0
- package/dist/src/services/provider-service.js +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
|
|
4
|
+
exports.resolveUsageStorePath = resolveUsageStorePath;
|
|
5
|
+
exports.resolveUsageLimits = resolveUsageLimits;
|
|
6
|
+
exports.normalizeEntryTitle = normalizeEntryTitle;
|
|
7
|
+
exports.titleUsageKey = titleUsageKey;
|
|
8
|
+
exports.ruleUsageKey = ruleUsageKey;
|
|
9
|
+
exports.readUsageStore = readUsageStore;
|
|
10
|
+
exports.sanitizeNote = sanitizeNote;
|
|
11
|
+
exports.recordOffers = recordOffers;
|
|
12
|
+
exports.recordFirings = recordFirings;
|
|
13
|
+
exports.noteTitleChange = noteTitleChange;
|
|
14
|
+
exports.pruneUsageStore = pruneUsageStore;
|
|
15
|
+
/**
|
|
16
|
+
* Issue #1103 — the usage record for learnings and rules.
|
|
17
|
+
*
|
|
18
|
+
* Two sides of one boundary, because only one of them is observable:
|
|
19
|
+
*
|
|
20
|
+
* OFFERED — written by the runtime with no agent involvement. The proxy composes
|
|
21
|
+
* the injected context block, so it knows which files it listed and,
|
|
22
|
+
* for the score-gated families, which entries in them were above
|
|
23
|
+
* threshold. That is what an offer means here.
|
|
24
|
+
* FIRED — attested by the agent at retrospective time. FRAIM cannot observe a
|
|
25
|
+
* file read, and a read is not a use, so influence has to be claimed
|
|
26
|
+
* rather than inferred.
|
|
27
|
+
*
|
|
28
|
+
* The record is metadata only (R16): entry identity, outcome, date, job, agent,
|
|
29
|
+
* model. Never prompts, diffs, or work content. Notes are reduced to one bounded
|
|
30
|
+
* line so a multi-line diff cannot be smuggled in through the attestation.
|
|
31
|
+
*
|
|
32
|
+
* Aggregate counters are kept in full; the detailed logs are bounded (R16a), on
|
|
33
|
+
* Mem0's precedent of retaining the last 20 accesses rather than an unbounded
|
|
34
|
+
* behavioural history.
|
|
35
|
+
*
|
|
36
|
+
* The store lives inside the manager layer's content root, which is the layer the
|
|
37
|
+
* existing GDPR guardrail blocks from a git backend without an explicit override
|
|
38
|
+
* (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
|
|
39
|
+
* second guardrail here.
|
|
40
|
+
*
|
|
41
|
+
* It is derived data (R17): deleting it loses no lesson, because every firing is
|
|
42
|
+
* still written in the retrospective that attested it and every offer is
|
|
43
|
+
* re-recorded the next time the entry is delivered.
|
|
44
|
+
*/
|
|
45
|
+
const fs_1 = require("fs");
|
|
46
|
+
const path_1 = require("path");
|
|
47
|
+
const pack_home_1 = require("../cli/utils/pack-home");
|
|
48
|
+
const artifact_retention_cleanup_1 = require("./artifact-retention-cleanup");
|
|
49
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
50
|
+
// ── Vocabulary ───────────────────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* The five outcomes (spec: Outcome vocabulary). `ignored` and `not-offered` are
|
|
53
|
+
* the two that change what the manager should do: the first says fix the entry,
|
|
54
|
+
* the second says fix the delivery.
|
|
55
|
+
*/
|
|
56
|
+
exports.FIRING_OUTCOMES = ['prevented', 'applied', 'not-applicable', 'ignored', 'not-offered'];
|
|
57
|
+
/**
|
|
58
|
+
* R8: only these advance the decay clock. An entry that was offered, read, and
|
|
59
|
+
* correctly not relevant has not proved anything about its usefulness.
|
|
60
|
+
*/
|
|
61
|
+
exports.CLOCK_ADVANCING_OUTCOMES = ['prevented', 'applied'];
|
|
62
|
+
const STORE_VERSION = 1;
|
|
63
|
+
const STORE_FILE_NAME = 'learning-usage.json';
|
|
64
|
+
/** Default bounds. The firing bound follows Mem0's published 20-access retention. */
|
|
65
|
+
const DEFAULT_FIRING_LOG_LIMIT = 20;
|
|
66
|
+
/**
|
|
67
|
+
* The offer log is larger than the firing log because R7 has to answer "was this
|
|
68
|
+
* entry in context for that job" across a plausible recurrence window, which
|
|
69
|
+
* needs per-job offer detail rather than a counter.
|
|
70
|
+
*/
|
|
71
|
+
const DEFAULT_OFFER_LOG_LIMIT = 50;
|
|
72
|
+
/** Open Question 2: configurable and greater than a handful. */
|
|
73
|
+
const DEFAULT_RETIREMENT_OFFER_THRESHOLD = 10;
|
|
74
|
+
/** R16: one line, bounded, no fences. Long enough for a sentence, short enough to be useless as a transcript. */
|
|
75
|
+
const MAX_NOTE_LENGTH = 240;
|
|
76
|
+
// ── Paths and config ─────────────────────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* The store path, inside the manager layer so the existing git-backend guardrail
|
|
79
|
+
* covers it. `resolvePackHome` never touches the network and never throws.
|
|
80
|
+
*/
|
|
81
|
+
function resolveUsageStorePath() {
|
|
82
|
+
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('manager').contentRoot, 'usage', STORE_FILE_NAME);
|
|
83
|
+
}
|
|
84
|
+
function readWorkspaceConfig(workspaceRoot) {
|
|
85
|
+
try {
|
|
86
|
+
const configPath = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
|
|
87
|
+
if (!(0, fs_1.existsSync)(configPath))
|
|
88
|
+
return {};
|
|
89
|
+
return JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function positiveInt(value, fallback) {
|
|
96
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
97
|
+
}
|
|
98
|
+
function resolveUsageLimits(workspaceRoot) {
|
|
99
|
+
const usage = readWorkspaceConfig(workspaceRoot)?.learning?.usage;
|
|
100
|
+
const block = usage && typeof usage === 'object' && !Array.isArray(usage) ? usage : {};
|
|
101
|
+
let retentionDays = -1;
|
|
102
|
+
try {
|
|
103
|
+
retentionDays = (0, artifact_retention_cleanup_1.resolveArtifactRetentionConfig)(workspaceRoot).values.learning_usage;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Retention config is advisory here; a malformed config must not stop a write.
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
firingLogLimit: positiveInt(block.firingLogLimit, DEFAULT_FIRING_LOG_LIMIT),
|
|
110
|
+
offerLogLimit: positiveInt(block.offerLogLimit, DEFAULT_OFFER_LOG_LIMIT),
|
|
111
|
+
retirementOfferThreshold: positiveInt(block.retirementOfferThreshold, DEFAULT_RETIREMENT_OFFER_THRESHOLD),
|
|
112
|
+
retentionDays: typeof retentionDays === 'number' ? retentionDays : -1,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
116
|
+
/** Normalize a title for keying. Case and whitespace only: never a similarity measure (D4). */
|
|
117
|
+
function normalizeEntryTitle(title) {
|
|
118
|
+
return title.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The fallback key for an entry that carries no `**Id**` line yet, so the feature
|
|
122
|
+
* works before the id migration has run. Namespaced by family so the same title
|
|
123
|
+
* in two families is two entries.
|
|
124
|
+
*/
|
|
125
|
+
function titleUsageKey(fileType, title) {
|
|
126
|
+
return `T:${fileType}:${normalizeEntryTitle(title)}`;
|
|
127
|
+
}
|
|
128
|
+
/** The key for a rule file, namespaced so it can never collide with a learning id. */
|
|
129
|
+
function ruleUsageKey(displayPath) {
|
|
130
|
+
return `R:${displayPath.replace(/\\/g, '/')}`;
|
|
131
|
+
}
|
|
132
|
+
// ── Read and write ───────────────────────────────────────────────────────────
|
|
133
|
+
function emptyStore() {
|
|
134
|
+
return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {} };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Is this parsed value a record every consumer can read without crashing?
|
|
138
|
+
*
|
|
139
|
+
* The shallow `record.offered && record.fired` this replaced was not enough. A
|
|
140
|
+
* record whose sub-objects exist but are empty passed it and then threw in
|
|
141
|
+
* `buildUsageReport` (`record.offered.log is not iterable`),
|
|
142
|
+
* `findRetirementCandidates` and `deriveStanding`, so usage reports and
|
|
143
|
+
* candidate analysis failed outright instead of degrading. That state is
|
|
144
|
+
* reachable: the store is a JSON file inside the manager home, which is commonly
|
|
145
|
+
* a synced folder, so an interrupted write or a partial sync produces valid JSON
|
|
146
|
+
* with a hollow record.
|
|
147
|
+
*
|
|
148
|
+
* Checks only what a consumer actually dereferences, so a record carrying an
|
|
149
|
+
* unknown extra field is still accepted and a future field addition does not
|
|
150
|
+
* silently start discarding history.
|
|
151
|
+
*/
|
|
152
|
+
function isUsableRecord(value) {
|
|
153
|
+
if (!value || typeof value !== 'object')
|
|
154
|
+
return false;
|
|
155
|
+
const record = value;
|
|
156
|
+
const offered = record.offered;
|
|
157
|
+
const fired = record.fired;
|
|
158
|
+
if (!offered || typeof offered !== 'object' || !fired || typeof fired !== 'object')
|
|
159
|
+
return false;
|
|
160
|
+
return Array.isArray(offered.log)
|
|
161
|
+
&& typeof offered.total === 'number'
|
|
162
|
+
&& typeof offered.aboveThreshold === 'number'
|
|
163
|
+
&& !!offered.byModel && typeof offered.byModel === 'object'
|
|
164
|
+
&& Array.isArray(fired.log)
|
|
165
|
+
&& typeof fired.total === 'number'
|
|
166
|
+
&& !!fired.byOutcome && typeof fired.byOutcome === 'object'
|
|
167
|
+
&& Array.isArray(record.titles);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A missing or corrupt store reads as empty. It is derived data, so losing it is
|
|
171
|
+
* recoverable and must never break the loader that reads it.
|
|
172
|
+
*/
|
|
173
|
+
function readUsageStore(storePath = resolveUsageStorePath()) {
|
|
174
|
+
try {
|
|
175
|
+
if (!(0, fs_1.existsSync)(storePath))
|
|
176
|
+
return emptyStore();
|
|
177
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
|
|
178
|
+
// `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
|
|
179
|
+
// Object.values yields whatever the array held and every consumer that reads
|
|
180
|
+
// `entry.offered` throws on it.
|
|
181
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.entries
|
|
182
|
+
|| typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
183
|
+
return emptyStore();
|
|
184
|
+
}
|
|
185
|
+
// Drop any value that is not a usable record rather than handing a caller
|
|
186
|
+
// something it will crash on. A partially corrupt store degrades to the records
|
|
187
|
+
// that are still readable, which is the right behaviour for derived data.
|
|
188
|
+
const entries = {};
|
|
189
|
+
for (const [key, value] of Object.entries(parsed.entries)) {
|
|
190
|
+
if (isUsableRecord(value))
|
|
191
|
+
entries[key] = value;
|
|
192
|
+
}
|
|
193
|
+
return { version: parsed.version ?? STORE_VERSION, updatedAt: parsed.updatedAt ?? emptyStore().updatedAt, entries };
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return emptyStore();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function writeUsageStore(store, storePath = resolveUsageStorePath()) {
|
|
200
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
|
|
201
|
+
(0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
202
|
+
}
|
|
203
|
+
// ── Mutation helpers ─────────────────────────────────────────────────────────
|
|
204
|
+
function emptyOutcomeCounts() {
|
|
205
|
+
return { prevented: 0, applied: 0, 'not-applicable': 0, ignored: 0, 'not-offered': 0 };
|
|
206
|
+
}
|
|
207
|
+
function ensureRecord(store, key, family, fileType, level) {
|
|
208
|
+
const existing = store.entries[key];
|
|
209
|
+
if (existing) {
|
|
210
|
+
if (fileType && !existing.fileType)
|
|
211
|
+
existing.fileType = fileType;
|
|
212
|
+
if (level && !existing.level)
|
|
213
|
+
existing.level = level;
|
|
214
|
+
return existing;
|
|
215
|
+
}
|
|
216
|
+
const created = {
|
|
217
|
+
key,
|
|
218
|
+
family,
|
|
219
|
+
titles: [],
|
|
220
|
+
fileType: fileType ?? null,
|
|
221
|
+
level: level ?? null,
|
|
222
|
+
offered: { total: 0, aboveThreshold: 0, byModel: {}, lastOfferedAt: null, log: [] },
|
|
223
|
+
fired: {
|
|
224
|
+
total: 0, byOutcome: emptyOutcomeCounts(), byModel: {},
|
|
225
|
+
firstFiredAt: null, lastFiredAt: null, lastAttestedAt: null, log: [], seenSources: [],
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
store.entries[key] = created;
|
|
229
|
+
return created;
|
|
230
|
+
}
|
|
231
|
+
function rememberTitle(record, title) {
|
|
232
|
+
if (!title)
|
|
233
|
+
return;
|
|
234
|
+
const trimmed = title.trim();
|
|
235
|
+
if (!trimmed)
|
|
236
|
+
return;
|
|
237
|
+
const normalized = normalizeEntryTitle(trimmed);
|
|
238
|
+
const already = record.titles.findIndex((t) => normalizeEntryTitle(t) === normalized);
|
|
239
|
+
// Already the most recent title: nothing to do. Guard the empty case explicitly,
|
|
240
|
+
// because findIndex returns -1 and length - 1 is also -1 on an empty list.
|
|
241
|
+
if (record.titles.length > 0 && already === record.titles.length - 1)
|
|
242
|
+
return;
|
|
243
|
+
if (already >= 0)
|
|
244
|
+
record.titles.splice(already, 1);
|
|
245
|
+
record.titles.push(trimmed);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* R16: reduce an attested note to a single bounded line. This is the control that
|
|
249
|
+
* keeps a diff, a prompt, or a transcript out of a behavioural record.
|
|
250
|
+
*/
|
|
251
|
+
function sanitizeNote(note) {
|
|
252
|
+
const oneLine = String(note ?? '')
|
|
253
|
+
.replace(/```+/g, ' ')
|
|
254
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
255
|
+
.replace(/\s+/g, ' ')
|
|
256
|
+
.trim();
|
|
257
|
+
return oneLine.length > MAX_NOTE_LENGTH ? `${oneLine.slice(0, MAX_NOTE_LENGTH - 1).trimEnd()}…` : oneLine;
|
|
258
|
+
}
|
|
259
|
+
function maxDate(a, b) {
|
|
260
|
+
if (!a)
|
|
261
|
+
return b;
|
|
262
|
+
return b > a ? b : a;
|
|
263
|
+
}
|
|
264
|
+
function minDate(a, b) {
|
|
265
|
+
if (!a)
|
|
266
|
+
return b;
|
|
267
|
+
return b < a ? b : a;
|
|
268
|
+
}
|
|
269
|
+
function isValidDate(value) {
|
|
270
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(value).getTime());
|
|
271
|
+
}
|
|
272
|
+
function withinRetention(date, retentionDays, now) {
|
|
273
|
+
if (retentionDays < 0)
|
|
274
|
+
return true;
|
|
275
|
+
const parsed = new Date(date).getTime();
|
|
276
|
+
if (Number.isNaN(parsed))
|
|
277
|
+
return true;
|
|
278
|
+
return (now.getTime() - parsed) / 86_400_000 <= retentionDays;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Trim one entry's logs: drop records outside the retention window, then keep the
|
|
282
|
+
* newest N. Aggregates are never touched, so R16a's second criterion holds.
|
|
283
|
+
*/
|
|
284
|
+
function trimLogs(record, limits, now) {
|
|
285
|
+
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
|
|
286
|
+
record.offered.log = record.offered.log
|
|
287
|
+
.filter((r) => withinRetention(r.date, limits.retentionDays, now))
|
|
288
|
+
.sort(byDate)
|
|
289
|
+
.slice(-limits.offerLogLimit);
|
|
290
|
+
record.fired.log = record.fired.log
|
|
291
|
+
.filter((r) => withinRetention(r.date, limits.retentionDays, now))
|
|
292
|
+
.sort(byDate)
|
|
293
|
+
.slice(-limits.firingLogLimit);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* R1 and R2: write one offer record per listed entry, without any agent
|
|
297
|
+
* involvement and without depending on the agent reading the file.
|
|
298
|
+
*/
|
|
299
|
+
function recordOffers(offers, options) {
|
|
300
|
+
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
301
|
+
const now = options.now ?? new Date();
|
|
302
|
+
const store = readUsageStore(storePath);
|
|
303
|
+
let recorded = 0;
|
|
304
|
+
for (const offer of offers) {
|
|
305
|
+
if (!offer.key || !isValidDate(offer.date))
|
|
306
|
+
continue;
|
|
307
|
+
const record = ensureRecord(store, offer.key, offer.family, offer.fileType, offer.level);
|
|
308
|
+
rememberTitle(record, offer.title);
|
|
309
|
+
record.offered.total += 1;
|
|
310
|
+
if (offer.aboveThreshold)
|
|
311
|
+
record.offered.aboveThreshold += 1;
|
|
312
|
+
if (offer.model)
|
|
313
|
+
record.offered.byModel[offer.model] = (record.offered.byModel[offer.model] ?? 0) + 1;
|
|
314
|
+
record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, offer.date);
|
|
315
|
+
record.offered.log.push({
|
|
316
|
+
date: offer.date,
|
|
317
|
+
job: offer.job,
|
|
318
|
+
agent: offer.agent ?? null,
|
|
319
|
+
model: offer.model ?? null,
|
|
320
|
+
aboveThreshold: Boolean(offer.aboveThreshold),
|
|
321
|
+
});
|
|
322
|
+
trimLogs(record, options.limits, now);
|
|
323
|
+
recorded += 1;
|
|
324
|
+
}
|
|
325
|
+
if (recorded > 0) {
|
|
326
|
+
store.updatedAt = now.toISOString();
|
|
327
|
+
writeUsageStore(store, storePath);
|
|
328
|
+
}
|
|
329
|
+
return { recorded, entries: Object.keys(store.entries).length };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* R4 and R6: write the attested firings. Idempotent per (entry, attestation
|
|
333
|
+
* source) so a re-run, or a backfill that overlaps a live attestation, cannot
|
|
334
|
+
* inflate a count.
|
|
335
|
+
*/
|
|
336
|
+
function recordFirings(firings, options) {
|
|
337
|
+
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
338
|
+
const now = options.now ?? new Date();
|
|
339
|
+
const store = readUsageStore(storePath);
|
|
340
|
+
let recorded = 0;
|
|
341
|
+
let alreadyRecorded = 0;
|
|
342
|
+
for (const firing of firings) {
|
|
343
|
+
if (!exports.FIRING_OUTCOMES.includes(firing.outcome)) {
|
|
344
|
+
throw new Error(`Unknown firing outcome "${firing.outcome}". Use one of: ${exports.FIRING_OUTCOMES.join(', ')}.`);
|
|
345
|
+
}
|
|
346
|
+
if (!firing.key || !isValidDate(firing.date))
|
|
347
|
+
continue;
|
|
348
|
+
const record = ensureRecord(store, firing.key, firing.family, firing.fileType, firing.level);
|
|
349
|
+
if (firing.sourceRef && record.fired.seenSources.includes(firing.sourceRef)) {
|
|
350
|
+
alreadyRecorded += 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
rememberTitle(record, firing.title);
|
|
354
|
+
record.fired.total += 1;
|
|
355
|
+
record.fired.byOutcome[firing.outcome] += 1;
|
|
356
|
+
record.fired.lastAttestedAt = maxDate(record.fired.lastAttestedAt, firing.date);
|
|
357
|
+
if (exports.CLOCK_ADVANCING_OUTCOMES.includes(firing.outcome)) {
|
|
358
|
+
record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, firing.date);
|
|
359
|
+
record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, firing.date);
|
|
360
|
+
if (firing.model)
|
|
361
|
+
record.fired.byModel[firing.model] = (record.fired.byModel[firing.model] ?? 0) + 1;
|
|
362
|
+
}
|
|
363
|
+
record.fired.log.push({
|
|
364
|
+
date: firing.date,
|
|
365
|
+
outcome: firing.outcome,
|
|
366
|
+
job: firing.job,
|
|
367
|
+
agent: firing.agent ?? null,
|
|
368
|
+
model: firing.model ?? null,
|
|
369
|
+
note: sanitizeNote(firing.note),
|
|
370
|
+
source: firing.source ?? 'attested',
|
|
371
|
+
});
|
|
372
|
+
if (firing.sourceRef)
|
|
373
|
+
record.fired.seenSources.push(firing.sourceRef);
|
|
374
|
+
trimLogs(record, options.limits, now);
|
|
375
|
+
recorded += 1;
|
|
376
|
+
}
|
|
377
|
+
if (recorded > 0 || alreadyRecorded > 0) {
|
|
378
|
+
store.updatedAt = now.toISOString();
|
|
379
|
+
writeUsageStore(store, storePath);
|
|
380
|
+
}
|
|
381
|
+
return { recorded, alreadyRecorded };
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* R5: record that an entry's title changed, so a firing written before the edit
|
|
385
|
+
* still resolves to it. Identity lives on the entry; this keeps the store's title
|
|
386
|
+
* index able to follow a rename.
|
|
387
|
+
*/
|
|
388
|
+
function noteTitleChange(key, newTitle, options) {
|
|
389
|
+
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
390
|
+
const store = readUsageStore(storePath);
|
|
391
|
+
const record = store.entries[key];
|
|
392
|
+
if (!record)
|
|
393
|
+
return;
|
|
394
|
+
rememberTitle(record, newTitle);
|
|
395
|
+
store.updatedAt = (options.now ?? new Date()).toISOString();
|
|
396
|
+
writeUsageStore(store, storePath);
|
|
397
|
+
}
|
|
398
|
+
/** Apply retention and log bounds across the whole store. Reports what it dropped. */
|
|
399
|
+
function pruneUsageStore(limits, options = {}) {
|
|
400
|
+
const storePath = options.storePath ?? resolveUsageStorePath();
|
|
401
|
+
const now = options.now ?? new Date();
|
|
402
|
+
const store = readUsageStore(storePath);
|
|
403
|
+
let prunedOfferRecords = 0;
|
|
404
|
+
let prunedFiringRecords = 0;
|
|
405
|
+
for (const record of Object.values(store.entries)) {
|
|
406
|
+
const offersBefore = record.offered.log.length;
|
|
407
|
+
const firingsBefore = record.fired.log.length;
|
|
408
|
+
trimLogs(record, limits, now);
|
|
409
|
+
prunedOfferRecords += offersBefore - record.offered.log.length;
|
|
410
|
+
prunedFiringRecords += firingsBefore - record.fired.log.length;
|
|
411
|
+
}
|
|
412
|
+
if (prunedOfferRecords > 0 || prunedFiringRecords > 0) {
|
|
413
|
+
store.updatedAt = now.toISOString();
|
|
414
|
+
writeUsageStore(store, storePath);
|
|
415
|
+
}
|
|
416
|
+
return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length };
|
|
417
|
+
}
|
|
@@ -70,6 +70,7 @@ const usage_collector_js_1 = require("./usage-collector.js");
|
|
|
70
70
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
71
71
|
const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
|
|
72
72
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
73
|
+
const learning_usage_store_js_1 = require("./learning-usage-store.js");
|
|
73
74
|
const learning_domains_js_1 = require("../config/learning-domains.js");
|
|
74
75
|
const skill_include_dedup_js_1 = require("./skill-include-dedup.js");
|
|
75
76
|
/**
|
|
@@ -1371,6 +1372,7 @@ class FraimLocalMCPServer {
|
|
|
1371
1372
|
responseText += teamContextSection;
|
|
1372
1373
|
this.log(`[req:${requestId}] Injected team context from ${workspaceRoot}`);
|
|
1373
1374
|
}
|
|
1375
|
+
this.recordUsageOffers(workspaceRoot, userEmail, false, null, 'session', requestId);
|
|
1374
1376
|
}
|
|
1375
1377
|
if (this.latestConnectSyncWarning) {
|
|
1376
1378
|
responseText += `\n\n## Local Catalog\n${this.latestConnectSyncWarning}`;
|
|
@@ -1391,10 +1393,51 @@ class FraimLocalMCPServer {
|
|
|
1391
1393
|
finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection + teamContextSection;
|
|
1392
1394
|
this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
|
|
1393
1395
|
}
|
|
1396
|
+
this.recordUsageOffers(workspaceRoot, userEmail, true, jobDomain, String(args.job ?? 'unknown'), requestId);
|
|
1394
1397
|
}
|
|
1395
1398
|
}
|
|
1396
1399
|
return this.processResponseWithHydration(finalizedResponse, requestSessionId);
|
|
1397
1400
|
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Issue #1103 R1, R2 and R3: record what this context injection delivered.
|
|
1403
|
+
*
|
|
1404
|
+
* Written here because this is the only place that knows all of it at once: the
|
|
1405
|
+
* files that were listed, the job, the agent, and the model. Nothing depends on
|
|
1406
|
+
* the agent reading anything, which is R2, and the three auto-loaded rule files
|
|
1407
|
+
* get a record for the first time, which is R3.
|
|
1408
|
+
*
|
|
1409
|
+
* Never throws and never blocks the response. Usage data is derived and optional:
|
|
1410
|
+
* a failure here must not change what the agent is given.
|
|
1411
|
+
*/
|
|
1412
|
+
recordUsageOffers(workspaceRoot, userEmail, forJob, domain, job, requestId) {
|
|
1413
|
+
try {
|
|
1414
|
+
const offered = [
|
|
1415
|
+
...(0, learning_context_builder_js_1.collectOfferedLearningEntries)(workspaceRoot, userEmail, forJob, domain),
|
|
1416
|
+
...(0, learning_context_builder_js_1.collectOfferedRuleFiles)(workspaceRoot, forJob),
|
|
1417
|
+
];
|
|
1418
|
+
if (offered.length === 0)
|
|
1419
|
+
return;
|
|
1420
|
+
const collector = this.usageCollector;
|
|
1421
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
1422
|
+
const limits = (0, learning_usage_store_js_1.resolveUsageLimits)(workspaceRoot);
|
|
1423
|
+
const result = (0, learning_usage_store_js_1.recordOffers)(offered.map((entry) => ({
|
|
1424
|
+
key: entry.key,
|
|
1425
|
+
family: entry.family,
|
|
1426
|
+
title: entry.title,
|
|
1427
|
+
fileType: entry.fileType,
|
|
1428
|
+
level: entry.level,
|
|
1429
|
+
aboveThreshold: entry.aboveThreshold,
|
|
1430
|
+
job,
|
|
1431
|
+
date,
|
|
1432
|
+
agent: collector?.getAgentName() ?? null,
|
|
1433
|
+
model: collector?.getAgentModel() ?? null,
|
|
1434
|
+
})), { limits });
|
|
1435
|
+
this.log(`[req:${requestId}] Recorded ${result.recorded} usage offers for job ${job}`);
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
this.log(`[req:${requestId}] Usage offer recording skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1398
1441
|
/**
|
|
1399
1442
|
* Resolve the learning domain for a `get_fraim_job` request (issue #806) from
|
|
1400
1443
|
* the job's registry path, so the loader injects global + that domain's
|
|
@@ -12,7 +12,7 @@ const PROVIDERS = {
|
|
|
12
12
|
description: 'GitHub repository and issue management',
|
|
13
13
|
capabilities: ['code', 'issues', 'integrated'],
|
|
14
14
|
docsUrl: 'https://github.com/settings/tokens',
|
|
15
|
-
setupInstructions: '
|
|
15
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current GitHub and host guidance',
|
|
16
16
|
mcpServer: {
|
|
17
17
|
type: 'http',
|
|
18
18
|
url: 'https://api.githubcopilot.com/mcp/'
|
|
@@ -25,7 +25,7 @@ const PROVIDERS = {
|
|
|
25
25
|
description: 'GitLab repository and issue management',
|
|
26
26
|
capabilities: ['code', 'issues', 'integrated'],
|
|
27
27
|
docsUrl: 'https://gitlab.com/-/profile/personal_access_tokens',
|
|
28
|
-
setupInstructions: '
|
|
28
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current GitLab and host guidance',
|
|
29
29
|
mcpServer: {
|
|
30
30
|
type: 'http',
|
|
31
31
|
url: 'https://gitlab.com/api/v4/mcp',
|
|
@@ -49,7 +49,7 @@ const PROVIDERS = {
|
|
|
49
49
|
}
|
|
50
50
|
],
|
|
51
51
|
docsUrl: 'https://dev.azure.com',
|
|
52
|
-
setupInstructions: '
|
|
52
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current Azure DevOps and host guidance',
|
|
53
53
|
mcpServer: {
|
|
54
54
|
type: 'stdio',
|
|
55
55
|
command: 'npx',
|
|
@@ -91,7 +91,7 @@ const PROVIDERS = {
|
|
|
91
91
|
}
|
|
92
92
|
],
|
|
93
93
|
docsUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
|
|
94
|
-
setupInstructions: '
|
|
94
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current Jira and host guidance',
|
|
95
95
|
mcpServer: {
|
|
96
96
|
type: 'stdio',
|
|
97
97
|
command: 'uvx',
|