fraim 2.0.337 → 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
+ }
@@ -1612,17 +1612,8 @@ class FraimLocalMCPServer {
1612
1612
  // 4. After get_fraim_job succeeds, inject learning context with job-focus frame.
1613
1613
  if (toolName === 'get_fraim_job' && !finalizedResponse.error) {
1614
1614
  const text = finalizedResponse.result?.content?.[0]?.text;
1615
- const userEmail = this.ensureEngine().getUserEmail();
1616
- if (typeof text === 'string' && userEmail) {
1617
- const workspaceRoot = this.findProjectRoot() || process.cwd();
1618
- const jobDomain = await this.resolveJobDomainForJob(args.job, requestSessionId);
1619
- const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain);
1620
- const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
1621
- if (learningSection || teamContextSection) {
1622
- finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection + teamContextSection;
1623
- this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
1624
- }
1625
- this.recordUsageOffers(workspaceRoot, userEmail, true, jobDomain, String(args.job ?? 'unknown'), requestId);
1615
+ if (typeof text === 'string') {
1616
+ finalizedResponse.result.content[0].text = await this.appendJobContext(text, args.job, requestSessionId, requestId);
1626
1617
  }
1627
1618
  // Issue #1345: forward executionMode from get_fraim_job response to Hub so
1628
1619
  // the chip reads from run data (no local file read, no separate endpoint needed).
@@ -1640,6 +1631,22 @@ class FraimLocalMCPServer {
1640
1631
  }
1641
1632
  return this.processResponseWithHydration(finalizedResponse, requestSessionId);
1642
1633
  }
1634
+ /** Keep context delivery and offer attribution together for local and remote jobs. */
1635
+ async appendJobContext(text, jobName, requestSessionId, requestId) {
1636
+ const userEmail = this.ensureEngine().getUserEmail();
1637
+ if (!userEmail)
1638
+ return text;
1639
+ const workspaceRoot = this.findProjectRoot() || process.cwd();
1640
+ const jobDomain = await this.resolveJobDomainForJob(jobName, requestSessionId);
1641
+ const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain);
1642
+ const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
1643
+ if (learningSection || teamContextSection) {
1644
+ text += `\n\n---` + learningSection + teamContextSection;
1645
+ this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
1646
+ }
1647
+ this.recordUsageOffers(workspaceRoot, userEmail, true, jobDomain, String(jobName ?? 'unknown'), requestId);
1648
+ return text;
1649
+ }
1643
1650
  /**
1644
1651
  * Issue #1103 R1, R2 and R3: record what this context injection delivered.
1645
1652
  *
@@ -1672,12 +1679,17 @@ class FraimLocalMCPServer {
1672
1679
  job,
1673
1680
  date,
1674
1681
  agent: collector?.getAgentName() ?? null,
1675
- 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()),
1676
1685
  })), { limits });
1677
- 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)`);
1678
1687
  }
1679
1688
  catch (error) {
1680
- 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)}`);
1681
1693
  }
1682
1694
  }
1683
1695
  /**
@@ -2627,18 +2639,7 @@ class FraimLocalMCPServer {
2627
2639
  responseText = `${mentor.getCompactPhaseAuthority()}\n\n${responseText}`;
2628
2640
  responseText += `\n\n---\n\n**Job ID:** \`${jobId}\`\n\n**This job has phases.** Use \`seekMentoring\` with the jobId above to get phase-specific instructions.`;
2629
2641
  }
2630
- // Inject local learning context for job requests (RFC 177).
2631
- const userEmail = this.ensureEngine().getUserEmail();
2632
- if (userEmail) {
2633
- const workspaceRoot = this.findProjectRoot() || process.cwd();
2634
- const jobDomain = await this.resolveJobDomainForJob(name, requestSessionId);
2635
- const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain);
2636
- const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
2637
- if (learningSection || teamContextSection) {
2638
- responseText += `\n\n---` + learningSection + teamContextSection;
2639
- this.log(`✅ Injected job-focus learning/team context for ${userEmail} (local override path, domain: ${jobDomain ?? 'global'})`);
2640
- }
2641
- }
2642
+ responseText = await this.appendJobContext(responseText, name, requestSessionId, requestId);
2642
2643
  return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText);
2643
2644
  }
2644
2645
  }
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.337",
3
+ "version": "2.0.339",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "fraim": "bin/fraim.js"
8
8
  },
9
9
  "files": [
10
+ "dist/registry/scripts/learning/learning-domains.json",
11
+ "dist/registry/scripts/learning/entry-format.js",
10
12
  "dist/src/local-mcp-server/",
11
13
  "dist/src/cli/",
12
14
  "!dist/src/cli/fraim-hub.js",