fraim 2.0.338 → 2.0.339

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.
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPACTION_ADVISORY_BYTES = void 0;
4
+ exports.listUnmergedStoreCopies = listUnmergedStoreCopies;
5
+ exports.findTwinPairs = findTwinPairs;
6
+ exports.buildVerifyReport = buildVerifyReport;
7
+ /**
8
+ * Issue #1752 E2 — the integrity picture for the usage record, in one output.
9
+ *
10
+ * Every defect in issue #1752 was found by a manager reading raw JSON during a
11
+ * `sleep-on-learnings` run, one at a time, over one session. This is the command
12
+ * that answers "can I trust what the record just told me" before acting on a
13
+ * recommendation, and it is the regression guard that makes every defect class
14
+ * visible in one place instead of one at a time.
15
+ *
16
+ * It reports and never blocks. Nothing here treats a finding as a gate: it states
17
+ * what it found, and the manager decides.
18
+ *
19
+ * ## Why there is no repair command here
20
+ *
21
+ * The usage record is derived data (#1103 R17): every firing is also written in
22
+ * the retrospective that attested it, and every offer is re-recorded the next
23
+ * time the entry is delivered. So the repair for a store that has gone wrong is
24
+ * to delete it and rebuild, not to reconcile it in place.
25
+ *
26
+ * That is not a preference, it is what the numbers say. Measured on a 733-file
27
+ * retrospective corpus with four unmerged store copies beside the canonical one:
28
+ *
29
+ * delete `usage/` then `learning-usage backfill` -> 149 entries, 2874 firings,
30
+ * 0 split identities,
31
+ * 1 clean model bucket
32
+ *
33
+ * A rebuild lands with zero split identities and one model bucket for free,
34
+ * because the identity resolution and the model normalization fix the causes and
35
+ * a rebuilt store carries no legacy shape. The only thing an in-place
36
+ * reconciliation recovers beyond that is old firings whose retrospectives
37
+ * retention has already deleted, and a decay clock reads the most recent firing
38
+ * rather than the count. What a rebuild does cost is the offer history, which
39
+ * rebuilds itself from live traffic and degrades toward `undetermined` and
40
+ * hold-back in the meantime, which is this module's posture anyway.
41
+ *
42
+ * So `verify` names the rebuild when it finds a stale shape, and the destructive
43
+ * step stays the manager's.
44
+ */
45
+ const fs_1 = require("fs");
46
+ const path_1 = require("path");
47
+ const learning_usage_store_1 = require("./learning-usage-store");
48
+ const learning_usage_events_1 = require("./learning-usage-events");
49
+ const learning_usage_projection_1 = require("./learning-usage-projection");
50
+ const CANONICAL_FILE_NAME = 'learning-usage.json';
51
+ /** `learning-usage.json` plus anything a sync client forked off it. */
52
+ const STORE_COPY_PATTERN = /^learning-usage(-.+)?\.json$/;
53
+ /**
54
+ * The size past which a cold fold stops fitting the 150 ms context-injection
55
+ * budget. Spike S4 measured 84 ms at 9.86 MB and 794 ms at 79 MB, so the budget
56
+ * runs out around here. Compaction is operator-triggered (`learning-usage
57
+ * prune`), so this is the number that tells the operator when to run it.
58
+ */
59
+ exports.COMPACTION_ADVISORY_BYTES = 16 * 1024 * 1024;
60
+ /**
61
+ * Deliberately looser than the store's own `isUsableRecord`.
62
+ *
63
+ * That one guards what a consumer will dereference, so it drops anything it
64
+ * cannot hand out safely. This one decides what `verify` will count, and a record
65
+ * it rejects is a finding that never gets reported, so it checks only the two
66
+ * counter objects and the title list.
67
+ */
68
+ function usableRecord(value) {
69
+ if (!value || typeof value !== 'object')
70
+ return false;
71
+ const record = value;
72
+ return !!record.offered && typeof record.offered === 'object'
73
+ && !!record.fired && typeof record.fired === 'object'
74
+ && Array.isArray(record.titles);
75
+ }
76
+ function parseCopy(path) {
77
+ const summary = { path, readable: false, entries: 0, offers: 0, firings: 0 };
78
+ try {
79
+ const parsed = JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
80
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
81
+ summary.error = 'No readable entries object.';
82
+ return summary;
83
+ }
84
+ const entries = [];
85
+ for (const value of Object.values(parsed.entries)) {
86
+ if (usableRecord(value))
87
+ entries.push(value);
88
+ }
89
+ summary.readable = true;
90
+ summary.entries = entries.length;
91
+ summary.offers = entries.reduce((sum, r) => sum + (r.offered.total ?? 0), 0);
92
+ summary.firings = entries.reduce((sum, r) => sum + (r.fired.seenSources?.length ?? 0), 0);
93
+ }
94
+ catch (error) {
95
+ summary.error = error instanceof Error ? error.message : String(error);
96
+ }
97
+ return summary;
98
+ }
99
+ /** The store copies beside the canonical snapshot: history nothing reads. */
100
+ function listUnmergedStoreCopies(snapshotPath) {
101
+ const dir = (0, path_1.dirname)(snapshotPath);
102
+ if (!(0, fs_1.existsSync)(dir))
103
+ return [];
104
+ let names;
105
+ try {
106
+ names = (0, fs_1.readdirSync)(dir);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ return names
112
+ .filter((name) => STORE_COPY_PATTERN.test(name) && name !== CANONICAL_FILE_NAME)
113
+ .sort()
114
+ .map((name) => parseCopy((0, path_1.join)(dir, name)));
115
+ }
116
+ /**
117
+ * Find lessons tracked under both a title-derived key and an id key.
118
+ *
119
+ * Matching is the title under the same family and nothing else. #1103 R5 forbids
120
+ * resolving a reference to the closest title, and reporting a wrong pair here
121
+ * would tell the manager two unrelated lessons are one.
122
+ */
123
+ function findTwinPairs(entries) {
124
+ const idRecordsByTitleKey = new Map();
125
+ for (const record of Object.values(entries)) {
126
+ if (record.key.startsWith('T:') || !record.fileType)
127
+ continue;
128
+ for (const title of record.titles) {
129
+ idRecordsByTitleKey.set((0, learning_usage_store_1.titleUsageKey)(record.fileType, title), record.key);
130
+ }
131
+ }
132
+ const pairs = [];
133
+ const unmatched = [];
134
+ for (const record of Object.values(entries)) {
135
+ if (!record.key.startsWith('T:'))
136
+ continue;
137
+ const idKey = idRecordsByTitleKey.get(record.key);
138
+ if (idKey)
139
+ pairs.push({ titleKey: record.key, idKey, title: record.titles[record.titles.length - 1] ?? record.key });
140
+ else
141
+ unmatched.push(record.key);
142
+ }
143
+ return { pairs, unmatched };
144
+ }
145
+ /**
146
+ * The three per-record defect classes, collected in one pass.
147
+ *
148
+ * Split out of `buildVerifyReport` so that function reads as what it is: an
149
+ * assembly of independently-computed parts.
150
+ */
151
+ function scanRecordsForIntegrity(records, offersEraStart) {
152
+ const invariantViolations = [];
153
+ const caseVariantModelBuckets = [];
154
+ const unknownModelAttribution = [];
155
+ for (const record of records) {
156
+ const title = record.titles[record.titles.length - 1] ?? record.key;
157
+ const violation = record.integrity ?? (0, learning_usage_store_1.integrityViolation)(record, offersEraStart);
158
+ if (violation) {
159
+ invariantViolations.push({
160
+ key: record.key, title,
161
+ offered: record.offered.total,
162
+ firedInEra: record.offered.total + violation.count,
163
+ detail: violation.detail,
164
+ });
165
+ }
166
+ for (const buckets of [record.offered.byModel, record.fired.byModel]) {
167
+ const byNormalized = new Map();
168
+ for (const raw of Object.keys(buckets)) {
169
+ const normalized = (0, learning_usage_store_1.normalizeModelName)(raw) ?? raw;
170
+ byNormalized.set(normalized, [...(byNormalized.get(normalized) ?? []), raw]);
171
+ }
172
+ for (const [normalized, raws] of byNormalized) {
173
+ if (raws.length > 1 || raws[0] !== normalized) {
174
+ caseVariantModelBuckets.push({ key: record.key, title, buckets: raws, normalized });
175
+ }
176
+ }
177
+ }
178
+ // Only a clock-advancing firing that produced no bucket means the model went
179
+ // unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
180
+ // set correctly, and that is information rather than a gap.
181
+ const clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
182
+ if (clockAdvancing > 0 && Object.keys(record.fired.byModel).length === 0) {
183
+ unknownModelAttribution.push({ key: record.key, title, fired: clockAdvancing });
184
+ }
185
+ }
186
+ return { invariantViolations, caseVariantModelBuckets, unknownModelAttribution };
187
+ }
188
+ function buildVerifyReport(options) {
189
+ const now = options.now ?? new Date();
190
+ const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(options.snapshotPath);
191
+ const store = (0, learning_usage_store_1.readUsageStore)(options.snapshotPath, { limits: options.limits, now, eventDir, refreshSnapshot: 'never' });
192
+ const snapshot = (0, learning_usage_store_1.readUsageSnapshot)(options.snapshotPath);
193
+ const logs = (0, learning_usage_events_1.listEventLogs)(eventDir);
194
+ const records = Object.values(store.entries);
195
+ const { invariantViolations, caseVariantModelBuckets, unknownModelAttribution } = scanRecordsForIntegrity(records, store.offersEraStart);
196
+ const twins = findTwinPairs(store.entries);
197
+ const storeCopies = listUnmergedStoreCopies(options.snapshotPath);
198
+ const checkpoints = snapshot.foldedThroughBytes ?? {};
199
+ const totalLogBytes = logs.reduce((sum, log) => sum + log.size, 0);
200
+ return {
201
+ snapshotPath: options.snapshotPath,
202
+ eventDir,
203
+ currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
204
+ entries: records.length,
205
+ twinPairs: twins.pairs,
206
+ unmatchedTitleKeys: twins.unmatched,
207
+ invariantViolations,
208
+ caseVariantModelBuckets,
209
+ unknownModelAttribution,
210
+ storeCopies,
211
+ eventLogs: logs.map((log) => ({ machineId: log.machineId, path: log.path, bytes: log.size })),
212
+ // The one number that makes a stale read visible: a snapshot that stops
213
+ // advancing while the log grows is a failing snapshot write, which S3 showed
214
+ // will happen intermittently on a synced folder and is otherwise silent.
215
+ checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.size - (checkpoints[log.machineId] ?? 0))])),
216
+ skippedLines: 0,
217
+ totalLogBytes,
218
+ compactionAdvisoryBytes: exports.COMPACTION_ADVISORY_BYTES,
219
+ compactionAdvised: totalLogBytes >= exports.COMPACTION_ADVISORY_BYTES,
220
+ rebuildAdvised: storeCopies.length > 0 || twins.pairs.length > 0,
221
+ };
222
+ }
@@ -1679,12 +1679,17 @@ class FraimLocalMCPServer {
1679
1679
  job,
1680
1680
  date,
1681
1681
  agent: collector?.getAgentName() ?? null,
1682
- model: collector?.getAgentModel() ?? null,
1682
+ // Issue #1752 C2: one model, one bucket. `GPT-5` and `gpt-5` were two,
1683
+ // splitting the denominator of every per-model conclusion about it.
1684
+ model: (0, learning_usage_store_js_1.normalizeModelName)(collector?.getAgentModel()),
1683
1685
  })), { limits });
1684
- this.log(`[req:${requestId}] Recorded ${result.recorded} usage offers for job ${job}`);
1686
+ this.log(`[req:${requestId}] Recorded ${result.recorded} usage offers for job ${job} (write mode: append)`);
1685
1687
  }
1686
1688
  catch (error) {
1687
- this.log(`[req:${requestId}] Usage offer recording skipped: ${error instanceof Error ? error.message : String(error)}`);
1689
+ // Issue #1752: name the log and the write mode. During the migration window
1690
+ // "which path did this write take" is the first question on any report of
1691
+ // missing data, and the answer was not in the logs.
1692
+ this.log(`[req:${requestId}] Usage offer recording skipped (append, log ${(0, learning_usage_store_js_1.resolveEventDir)((0, learning_usage_store_js_1.resolveUsageStorePath)())}): ${error instanceof Error ? error.message : String(error)}`);
1688
1693
  }
1689
1694
  }
1690
1695
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.338",
3
+ "version": "2.0.339",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {