fraim 2.0.338 → 2.0.340

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,207 @@
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
+ * ## Recovery boundary
20
+ *
21
+ * Rebuild only the snapshot, keeping the event directory intact. The retained
22
+ * events recover the offers and firings they cover; retrospective backfill can
23
+ * supplement older firings but cannot recreate offers. Normal compaction keeps
24
+ * lifetime aggregate totals in the snapshot, so a backup is required before
25
+ * discarding it if history already pruned from events must remain inspectable.
26
+ * Rebuilding cannot invent missing history or fix identities still present in
27
+ * the authoritative events. Verify reports those limitations rather than
28
+ * claiming that deleting the usage directory is a lossless repair.
29
+ */
30
+ const fs_1 = require("fs");
31
+ const path_1 = require("path");
32
+ const learning_usage_store_1 = require("./learning-usage-store");
33
+ const learning_usage_events_1 = require("./learning-usage-events");
34
+ const learning_usage_projection_1 = require("./learning-usage-projection");
35
+ const CANONICAL_FILE_NAME = 'learning-usage.json';
36
+ /** `learning-usage.json` plus anything a sync client forked off it. */
37
+ const STORE_COPY_PATTERN = /^learning-usage(-.+)?\.json$/;
38
+ /**
39
+ * The size past which a cold fold stops fitting the 150 ms context-injection
40
+ * budget. Spike S4 measured 84 ms at 9.86 MB and 794 ms at 79 MB, so the budget
41
+ * runs out around here. Compaction is operator-triggered (`learning-usage
42
+ * prune`), so this is the number that tells the operator when to run it.
43
+ */
44
+ exports.COMPACTION_ADVISORY_BYTES = 16 * 1024 * 1024;
45
+ /**
46
+ * Deliberately looser than the store's own `isUsableRecord`.
47
+ *
48
+ * That one guards what a consumer will dereference, so it drops anything it
49
+ * cannot hand out safely. This one decides what `verify` will count, and a record
50
+ * it rejects is a finding that never gets reported, so it checks only the two
51
+ * counter objects and the title list.
52
+ */
53
+ function usableRecord(value) {
54
+ if (!value || typeof value !== 'object')
55
+ return false;
56
+ const record = value;
57
+ return !!record.offered && typeof record.offered === 'object'
58
+ && !!record.fired && typeof record.fired === 'object'
59
+ && Array.isArray(record.titles);
60
+ }
61
+ function parseCopy(path) {
62
+ const summary = { path, readable: false, entries: 0, offers: 0, firings: 0 };
63
+ try {
64
+ const parsed = JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
65
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
66
+ summary.error = 'No readable entries object.';
67
+ return summary;
68
+ }
69
+ const entries = [];
70
+ for (const value of Object.values(parsed.entries)) {
71
+ if (usableRecord(value))
72
+ entries.push(value);
73
+ }
74
+ summary.readable = true;
75
+ summary.entries = entries.length;
76
+ summary.offers = entries.reduce((sum, r) => sum + (r.offered.total ?? 0), 0);
77
+ summary.firings = entries.reduce((sum, r) => sum + (r.fired.seenSources?.length ?? 0), 0);
78
+ }
79
+ catch (error) {
80
+ summary.error = error instanceof Error ? error.message : String(error);
81
+ }
82
+ return summary;
83
+ }
84
+ /** The store copies beside the canonical snapshot: history nothing reads. */
85
+ function listUnmergedStoreCopies(snapshotPath) {
86
+ const dir = (0, path_1.dirname)(snapshotPath);
87
+ if (!(0, fs_1.existsSync)(dir))
88
+ return [];
89
+ let names;
90
+ try {
91
+ names = (0, fs_1.readdirSync)(dir);
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ return names
97
+ .filter((name) => STORE_COPY_PATTERN.test(name) && name !== CANONICAL_FILE_NAME)
98
+ .sort()
99
+ .map((name) => parseCopy((0, path_1.join)(dir, name)));
100
+ }
101
+ /**
102
+ * Find lessons tracked under both a title-derived key and an id key.
103
+ *
104
+ * Matching is the title under the same family and nothing else. #1103 R5 forbids
105
+ * resolving a reference to the closest title, and reporting a wrong pair here
106
+ * would tell the manager two unrelated lessons are one.
107
+ */
108
+ function findTwinPairs(entries) {
109
+ const idRecordsByTitleKey = new Map();
110
+ for (const record of Object.values(entries)) {
111
+ if (record.key.startsWith('T:') || !record.fileType)
112
+ continue;
113
+ for (const title of record.titles) {
114
+ idRecordsByTitleKey.set((0, learning_usage_store_1.titleUsageKey)(record.fileType, title), record.key);
115
+ }
116
+ }
117
+ const pairs = [];
118
+ const unmatched = [];
119
+ for (const record of Object.values(entries)) {
120
+ if (!record.key.startsWith('T:'))
121
+ continue;
122
+ const idKey = idRecordsByTitleKey.get(record.key);
123
+ if (idKey)
124
+ pairs.push({ titleKey: record.key, idKey, title: record.titles[record.titles.length - 1] ?? record.key });
125
+ else
126
+ unmatched.push(record.key);
127
+ }
128
+ return { pairs, unmatched };
129
+ }
130
+ /**
131
+ * The three per-record defect classes, collected in one pass.
132
+ *
133
+ * Split out of `buildVerifyReport` so that function reads as what it is: an
134
+ * assembly of independently-computed parts.
135
+ */
136
+ function scanRecordsForIntegrity(records, offersEraStart) {
137
+ const invariantViolations = [];
138
+ const caseVariantModelBuckets = [];
139
+ const unknownModelAttribution = [];
140
+ for (const record of records) {
141
+ const title = record.titles[record.titles.length - 1] ?? record.key;
142
+ const violation = record.integrity ?? (0, learning_usage_store_1.integrityViolation)(record, offersEraStart);
143
+ if (violation) {
144
+ invariantViolations.push({
145
+ key: record.key, title,
146
+ offered: record.offered.total,
147
+ firedInEra: record.offered.total + violation.count,
148
+ detail: violation.detail,
149
+ });
150
+ }
151
+ for (const buckets of [record.offered.byModel, record.fired.byModel]) {
152
+ const byNormalized = new Map();
153
+ for (const raw of Object.keys(buckets)) {
154
+ const normalized = (0, learning_usage_store_1.normalizeModelName)(raw) ?? raw;
155
+ byNormalized.set(normalized, [...(byNormalized.get(normalized) ?? []), raw]);
156
+ }
157
+ for (const [normalized, raws] of byNormalized) {
158
+ if (raws.length > 1 || raws[0] !== normalized) {
159
+ caseVariantModelBuckets.push({ key: record.key, title, buckets: raws, normalized });
160
+ }
161
+ }
162
+ }
163
+ // Only a clock-advancing firing that produced no bucket means the model went
164
+ // unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
165
+ // set correctly, and that is information rather than a gap.
166
+ const clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
167
+ if (clockAdvancing > 0 && Object.keys(record.fired.byModel).length === 0) {
168
+ unknownModelAttribution.push({ key: record.key, title, fired: clockAdvancing });
169
+ }
170
+ }
171
+ return { invariantViolations, caseVariantModelBuckets, unknownModelAttribution };
172
+ }
173
+ function buildVerifyReport(options) {
174
+ const now = options.now ?? new Date();
175
+ const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(options.snapshotPath);
176
+ const store = (0, learning_usage_store_1.readUsageStore)(options.snapshotPath, { limits: options.limits, now, eventDir, refreshSnapshot: 'never' });
177
+ const snapshot = (0, learning_usage_store_1.readUsageSnapshot)(options.snapshotPath);
178
+ const logs = (0, learning_usage_events_1.listEventLogs)(eventDir);
179
+ const records = Object.values(store.entries);
180
+ const { invariantViolations, caseVariantModelBuckets, unknownModelAttribution } = scanRecordsForIntegrity(records, store.offersEraStart);
181
+ const twins = findTwinPairs(store.entries);
182
+ const storeCopies = listUnmergedStoreCopies(options.snapshotPath);
183
+ const checkpoints = snapshot.foldedThroughBytes ?? {};
184
+ const totalLogBytes = logs.reduce((sum, log) => sum + log.size, 0);
185
+ return {
186
+ snapshotPath: options.snapshotPath,
187
+ eventDir,
188
+ currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
189
+ entries: records.length,
190
+ twinPairs: twins.pairs,
191
+ unmatchedTitleKeys: twins.unmatched,
192
+ invariantViolations,
193
+ caseVariantModelBuckets,
194
+ unknownModelAttribution,
195
+ storeCopies,
196
+ eventLogs: logs.map((log) => ({ machineId: log.machineId, path: log.path, bytes: log.size })),
197
+ // The one number that makes a stale read visible: a snapshot that stops
198
+ // advancing while the log grows is a failing snapshot write, which S3 showed
199
+ // will happen intermittently on a synced folder and is otherwise silent.
200
+ checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.baseOffset + log.size - log.headerBytes - (checkpoints[log.machineId] ?? 0))])),
201
+ skippedLines: 0,
202
+ totalLogBytes,
203
+ compactionAdvisoryBytes: exports.COMPACTION_ADVISORY_BYTES,
204
+ compactionAdvised: totalLogBytes >= exports.COMPACTION_ADVISORY_BYTES,
205
+ rebuildAdvised: storeCopies.length > 0 || twins.pairs.length > 0,
206
+ };
207
+ }
@@ -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
  /**
@@ -2423,6 +2428,35 @@ class FraimLocalMCPServer {
2423
2428
  if (request.method === 'tools/call' && typeof toolName === 'string') {
2424
2429
  // 🔍 SMART DISPATCHER: Intercept mentoring and job/workflow tools for local overrides
2425
2430
  if (toolName === 'seekMentoring') {
2431
+ // #1766: report an unrenderable supplied handoff before any mentoring
2432
+ // success response, including when phase-policy enforcement is disabled.
2433
+ // Match the host's direct/evidence/findings precedence and JSON support.
2434
+ const suppliedHandoffs = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
2435
+ .filter((value) => value !== undefined && value !== null)
2436
+ .map((value) => {
2437
+ if (typeof value !== 'string')
2438
+ return value;
2439
+ try {
2440
+ return JSON.parse(value);
2441
+ }
2442
+ catch {
2443
+ return value;
2444
+ }
2445
+ });
2446
+ const suppliedHandoff = suppliedHandoffs.find((value) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.reviewRequired === 'boolean') ?? suppliedHandoffs[0];
2447
+ if (suppliedHandoffs.length > 0) {
2448
+ const normalizationError = (0, handoff_contracts_1.reviewHandoffNormalizationError)(suppliedHandoff);
2449
+ if (normalizationError) {
2450
+ return {
2451
+ jsonrpc: '2.0',
2452
+ id: request.id,
2453
+ result: {
2454
+ isError: true,
2455
+ content: [{ type: 'text', text: `Review handoff was not applied: ${normalizationError}. This response does not confirm delivery.` }],
2456
+ },
2457
+ };
2458
+ }
2459
+ }
2426
2460
  try {
2427
2461
  const mentor = this.getMentor(requestSessionId);
2428
2462
  const tutoringResponse = await mentor.handleMentoringRequest(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.338",
3
+ "version": "2.0.340",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "dist/registry/scripts/learning/learning-domains.json",
11
11
  "dist/registry/scripts/learning/entry-format.js",
12
+ "dist/registry/scripts/learning/completion-state.js",
12
13
  "dist/src/local-mcp-server/",
13
14
  "dist/src/cli/",
14
15
  "!dist/src/cli/fraim-hub.js",