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.
@@ -18,6 +18,18 @@ const learning_context_builder_1 = require("./learning-context-builder");
18
18
  const learning_usage_analysis_1 = require("./learning-usage-analysis");
19
19
  const learning_usage_store_1 = require("./learning-usage-store");
20
20
  const learning_usage_attestation_1 = require("./learning-usage-attestation");
21
+ const learning_usage_verify_1 = require("./learning-usage-verify");
22
+ const pack_home_1 = require("../cli/utils/pack-home");
23
+ /**
24
+ * The repair for a usage record that has gone wrong.
25
+ *
26
+ * The record is derived (#1103 R17), so the fix is to rebuild it rather than to
27
+ * reconcile it in place. Measured on a 733-file retrospective corpus: a rebuild
28
+ * lands with zero split identities and one model bucket, because the identity
29
+ * resolution and the model normalization fix the causes and a rebuilt store
30
+ * carries no legacy shape.
31
+ */
32
+ const REBUILD_NOTE = 'Delete the `usage/` directory and run `learning-usage backfill` to rebuild the record from the retrospectives. The record is derived: every firing is also written in the retrospective that attested it, so a rebuild restores one identity per lesson and one bucket per model. It costs the offer history, which rebuilds itself from live traffic.';
21
33
  function resolveWorkspaceRoot(options) {
22
34
  return node_path_1.default.resolve(options.workspaceRoot || options.root || process.cwd());
23
35
  }
@@ -38,6 +50,23 @@ function addCommonOptions(command) {
38
50
  .option('--user <email>', 'Active user email')
39
51
  .option('--json', 'Print JSON');
40
52
  }
53
+ /**
54
+ * Issue #1752 C1: every command that draws a per-model conclusion takes the model
55
+ * from its caller. It is never inferred from the record, because the record is
56
+ * shared by concurrent sessions from more than one agent on more than one
57
+ * machine, so inference reports whichever wrote last.
58
+ */
59
+ function addModelOption(command) {
60
+ return command.option('--model <model>', 'The model you are running under. Falls back to FRAIM_ACTIVE_MODEL, then reports unknown');
61
+ }
62
+ /**
63
+ * Issue #1752 B: the offer and firing log bounds are applied when the event log is
64
+ * folded, so a read states the bounds it wants rather than inheriting whatever the
65
+ * last writer used.
66
+ */
67
+ function readStore(workspaceRoot) {
68
+ return (0, learning_usage_store_1.readUsageStore)(undefined, { limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot) });
69
+ }
41
70
  // ── offers ───────────────────────────────────────────────────────────────────
42
71
  const offersCommand = addCommonOptions(new commander_1.Command('offers'))
43
72
  .description('List the learning entries and rule files the runtime delivered, so an attestation is written against the record rather than memory')
@@ -52,7 +81,7 @@ const offersCommand = addCommonOptions(new commander_1.Command('offers'))
52
81
  // actually delivered. Re-deriving from the files on disk answers a subtly
53
82
  // different question — what would be delivered now — and the two diverge if a
54
83
  // learning file changed during the job.
55
- const store = (0, learning_usage_store_1.readUsageStore)();
84
+ const store = readStore(workspaceRoot);
56
85
  const recorded = Object.values(store.entries)
57
86
  .filter((entry) => entry.offered.log.some((offer) => offer.job === options.job))
58
87
  .map((entry) => {
@@ -102,6 +131,7 @@ const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-fi
102
131
  .option('--agent <name>', 'Agent that attested')
103
132
  .option('--model <model>', 'Model that attested')
104
133
  .option('--legacy', 'Also accept the pre-#1103 prose form')
134
+ .option('--force', 'Record the firings even when they violate the scoped integrity invariant, flagging the entry instead of refusing. Use when the offers are known to be stranded in an unmerged store copy or an unsynced machine log')
105
135
  .action((options) => {
106
136
  const workspaceRoot = resolveWorkspaceRoot(options);
107
137
  const user = resolveUser(options);
@@ -118,6 +148,7 @@ const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-fi
118
148
  agent: options.agent ?? null,
119
149
  model: options.model ?? null,
120
150
  legacy: Boolean(options.legacy),
151
+ integrityMode: options.force ? 'flag' : 'throw',
121
152
  });
122
153
  emit(result, options.json, () => {
123
154
  const lines = [
@@ -151,17 +182,17 @@ const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
151
182
  // character, so an agent assembling `T:<family>:<title>` into a shell command
152
183
  // would be interpolating untrusted text into a command line. Passing the title
153
184
  // as its own argument keeps it a single argv entry.
154
- let key;
155
- if (options.entry) {
156
- key = String(options.entry);
157
- }
158
- else if (options.title && options.family) {
159
- key = (0, learning_usage_store_1.titleUsageKey)(String(options.family), String(options.title));
160
- }
161
- else {
185
+ if (!options.entry && !(options.title && options.family)) {
162
186
  throw new Error('Pass --entry <id>, or --title <title> together with --family <family>.');
163
187
  }
164
- const store = (0, learning_usage_store_1.readUsageStore)();
188
+ // #1752 A1: the same identity function the offer and firing paths use, so
189
+ // the CLI cannot ask about a key neither of them would have written.
190
+ const key = (0, learning_usage_store_1.resolveEntryIdentity)({
191
+ id: options.entry ? String(options.entry) : null,
192
+ fileType: options.family ? String(options.family) : null,
193
+ title: options.title ? String(options.title) : null,
194
+ }).key;
195
+ const store = readStore(workspaceRoot);
165
196
  const diagnosis = (0, learning_usage_analysis_1.classifyRecurrence)(store, {
166
197
  key,
167
198
  job: options.job,
@@ -177,14 +208,13 @@ const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
177
208
  ].join('\n'));
178
209
  });
179
210
  // ── candidates ───────────────────────────────────────────────────────────────
180
- const candidatesCommand = addCommonOptions(new commander_1.Command('candidates'))
211
+ const candidatesCommand = addModelOption(addCommonOptions(new commander_1.Command('candidates')))
181
212
  .description('List retirement candidates: offered often under the current model with no firing under it, paired with a structural signal')
182
- .option('--model <model>', 'Override the current model (defaults to the most recently offered-to model)')
183
213
  .option('--detector <path>', 'Path to the corpus hygiene detector that provides the structural signal')
184
214
  .action((options) => {
185
215
  const workspaceRoot = resolveWorkspaceRoot(options);
186
216
  const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
187
- const result = (0, learning_usage_analysis_1.findRetirementCandidates)((0, learning_usage_store_1.readUsageStore)(), {
217
+ const result = (0, learning_usage_analysis_1.findRetirementCandidates)(readStore(workspaceRoot), {
188
218
  workspaceRoot,
189
219
  limits,
190
220
  currentModel: options.model ?? undefined,
@@ -192,7 +222,14 @@ const candidatesCommand = addCommonOptions(new commander_1.Command('candidates')
192
222
  });
193
223
  emit(result, options.json, () => {
194
224
  if (result.candidates.length === 0) {
195
- return `No retirement candidates. Current model: ${result.currentModel ?? 'unknown'}; threshold ${result.offerThreshold} offers.`;
225
+ const lines = [`No retirement candidates. Current model: ${result.currentModel ?? 'unknown'}; threshold ${result.offerThreshold} offers.`];
226
+ if (result.reason)
227
+ lines.push('', result.reason);
228
+ const unknownAttribution = result.notCandidates.filter((c) => /attribution is unknown/i.test(c.reason));
229
+ if (unknownAttribution.length > 0) {
230
+ lines.push('', `${unknownAttribution.length} entr${unknownAttribution.length === 1 ? 'y was' : 'ies were'} held back because their firings carry no model, so how hard they are working under this model cannot be measured.`);
231
+ }
232
+ return lines.join('\n');
196
233
  }
197
234
  const lines = [`Current model: ${result.currentModel ?? 'unknown'}. Threshold: ${result.offerThreshold} offers.`, ''];
198
235
  for (const c of result.candidates) {
@@ -203,13 +240,13 @@ const candidatesCommand = addCommonOptions(new commander_1.Command('candidates')
203
240
  });
204
241
  });
205
242
  // ── standing ─────────────────────────────────────────────────────────────────
206
- const standingCommand = addCommonOptions(new commander_1.Command('standing'))
243
+ const standingCommand = addModelOption(addCommonOptions(new commander_1.Command('standing')))
207
244
  .description('Assess each entry as promoted, standard or demoted from its usage record, and list the ones whose standing should change')
208
245
  .option('--changes-only', 'Print only the entries whose standing should change')
209
246
  .action((options) => {
210
247
  const workspaceRoot = resolveWorkspaceRoot(options);
211
248
  const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
212
- const report = (0, learning_usage_analysis_1.assessStanding)((0, learning_usage_store_1.readUsageStore)(), { limits });
249
+ const report = (0, learning_usage_analysis_1.assessStanding)(readStore(workspaceRoot), { limits });
213
250
  const payload = options.changesOnly ? { ...report, assessments: report.changes } : report;
214
251
  emit(payload, options.json, () => {
215
252
  const rows = (options.changesOnly ? report.changes : report.assessments);
@@ -231,16 +268,17 @@ const standingCommand = addCommonOptions(new commander_1.Command('standing'))
231
268
  });
232
269
  });
233
270
  // ── report ───────────────────────────────────────────────────────────────────
234
- const reportCommand = addCommonOptions(new commander_1.Command('report'))
271
+ const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('report')))
235
272
  .description('Answer which learnings are still earning their place, from the record rather than from memory')
236
273
  .option('--window <days>', 'Reporting window', '90')
237
274
  .action((options) => {
238
275
  const workspaceRoot = resolveWorkspaceRoot(options);
239
276
  const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
240
- const report = (0, learning_usage_analysis_1.buildUsageReport)((0, learning_usage_store_1.readUsageStore)(), {
277
+ const report = (0, learning_usage_analysis_1.buildUsageReport)(readStore(workspaceRoot), {
241
278
  workspaceRoot,
242
279
  limits,
243
280
  windowDays: Number.parseInt(options.window, 10) || 90,
281
+ currentModel: options.model ?? undefined,
244
282
  });
245
283
  emit(report, options.json, () => {
246
284
  const lines = [
@@ -256,7 +294,13 @@ const reportCommand = addCommonOptions(new commander_1.Command('report'))
256
294
  }
257
295
  if (report.ignoredWhileInContext.length) {
258
296
  lines.push('', 'Ignored while in context');
259
- report.ignoredWhileInContext.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. In context and the work went against it, so the entry needs work rather than another recurrence.`));
297
+ // #1752 defect 8: this section selects on the `ignored` count, so it
298
+ // prints that count. Printing only the clock-advancing one made the
299
+ // section contradict its own evidence column.
300
+ report.ignoredWhileInContext.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Attested ignored ${e.ignored} time${e.ignored === 1 ? '' : 's'} (fired ${e.fired}). In context and the work went against it, so the entry needs work rather than another recurrence.`));
301
+ }
302
+ if (report.integrityFlagged > 0) {
303
+ lines.push('', `${report.integrityFlagged} entr${report.integrityFlagged === 1 ? 'y has' : 'ies have'} an integrity flag: more attested firings than recorded offers. Run \`learning-usage verify\` before acting on anything below.`);
260
304
  }
261
305
  lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
262
306
  return lines.join('\n');
@@ -343,6 +387,19 @@ function stampFile(filePath, fileType, apply) {
343
387
  return { file: filePath, entriesStamped: stamped, entriesAlreadyStamped: alreadyStamped };
344
388
  }
345
389
  const FILE_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
390
+ function defaultStampDirs(workspaceRoot) {
391
+ const dirs = [node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings')];
392
+ for (const level of ['org', 'manager']) {
393
+ try {
394
+ dirs.push(node_path_1.default.join((0, pack_home_1.resolvePackHome)(level).contentRoot, 'learnings'));
395
+ }
396
+ catch {
397
+ // A pack home that does not resolve is simply not stamped. Reported by the
398
+ // filesScanned count rather than failing the command.
399
+ }
400
+ }
401
+ return [...new Set(dirs)];
402
+ }
346
403
  function fileTypeFromName(fileName) {
347
404
  for (const type of FILE_TYPES) {
348
405
  if (fileName.includes(type))
@@ -356,9 +413,14 @@ const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
356
413
  .option('--apply', 'Write the changes. Without this flag the command reports what it would do')
357
414
  .action((options) => {
358
415
  const workspaceRoot = resolveWorkspaceRoot(options);
416
+ // #1752 A3: the default set is every tier the context reader merges, not
417
+ // the repo alone. `stamp-ids` never reaching the org and manager pack homes
418
+ // is half of defect 4: one lesson stamped in one tier and unstamped in the
419
+ // other resolved to two usage keys. Still opt-in behind --apply, and still a
420
+ // format migration that inserts one identifier line and touches no lesson text.
359
421
  const dirs = options.dir.length
360
422
  ? options.dir.map((d) => node_path_1.default.resolve(d))
361
- : [node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings')];
423
+ : defaultStampDirs(workspaceRoot);
362
424
  const results = [];
363
425
  for (const dir of dirs) {
364
426
  if (!node_fs_1.default.existsSync(dir))
@@ -380,7 +442,8 @@ const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
380
442
  files: results,
381
443
  };
382
444
  emit(payload, options.json, () => [
383
- `${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files.`,
445
+ `${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files in ${dirs.length} director${dirs.length === 1 ? 'y' : 'ies'}:`,
446
+ ...dirs.map((d) => ` ${d}`),
384
447
  `${payload.entriesAlreadyStamped} entries already carry an id.`,
385
448
  payload.apply ? '' : 'Re-run with --apply to write the changes.',
386
449
  ].filter(Boolean).join('\n'));
@@ -394,6 +457,52 @@ const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
394
457
  const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
395
458
  emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
396
459
  });
460
+ function mb(bytes) {
461
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
462
+ }
463
+ // ── verify ───────────────────────────────────────────────────────────────────
464
+ const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('verify')))
465
+ .description('Report the integrity of the usage record in one output: split identities, impossible counters, split model buckets, unmerged store copies, and how far each snapshot checkpoint is behind its log')
466
+ .action((options) => {
467
+ const workspaceRoot = resolveWorkspaceRoot(options);
468
+ const report = (0, learning_usage_verify_1.buildVerifyReport)({
469
+ snapshotPath: (0, learning_usage_store_1.resolveUsageStorePath)(),
470
+ limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot),
471
+ currentModel: options.model ?? undefined,
472
+ });
473
+ emit(report, options.json, () => {
474
+ const lines = [
475
+ `Usage record: ${report.entries} entries at ${report.snapshotPath}`,
476
+ `Current model: ${report.currentModel ?? 'unknown'}`,
477
+ '',
478
+ ];
479
+ const finding = (count, singular, plural, remedy) => count === 0 ? ` none: ${singular}` : ` ${count} ${count === 1 ? singular : plural}. ${remedy}`;
480
+ lines.push('Identity', finding(report.twinPairs.length, 'lesson tracked under two keys', 'lessons tracked under two keys', 'Its offers and its firings are on different records, so every count about it is partial.'), report.unmatchedTitleKeys.length
481
+ ? ` ${report.unmatchedTitleKeys.length} title-keyed record${report.unmatchedTitleKeys.length === 1 ? ' has' : 's have'} no id twin. Expected for entries no tier has stamped; run \`stamp-ids\` to reduce it.`
482
+ : ' every title-keyed record has been matched or has no twin', '', 'Counters', finding(report.invariantViolations.length, 'entry holds more attested firings than recorded offers', 'entries hold more attested firings than recorded offers', 'An entry cannot fire without being offered, so one of the two counters is wrong. History stranded in a store copy nothing reads is the usual cause.'), finding(report.caseVariantModelBuckets.length, 'model bucket differs only by case', 'model buckets differ only by case', 'One model spelled two ways splits its own denominator, so every per-model conclusion about it is computed on a fraction of its traffic.'), finding(report.unknownModelAttribution.length, 'entry fired with no model recorded', 'entries fired with no model recorded', 'Per-model retirement cannot be answered for these, and they are held back rather than counted as zero. These were attested before record-firings carried --agent and --model, so the model was never recorded against them and a rebuild cannot invent it; attestations recorded from now on carry both.'), '', 'Storage');
483
+ if (report.storeCopies.length === 0) {
484
+ lines.push(' one store file, no unmerged copies');
485
+ }
486
+ else {
487
+ lines.push(` ${report.storeCopies.length} unmerged store cop${report.storeCopies.length === 1 ? 'y' : 'ies'} beside the canonical snapshot:`);
488
+ for (const copy of report.storeCopies) {
489
+ lines.push(` ${copy.path}: ${copy.readable ? `${copy.entries} entries, ${copy.offers} offers, ${copy.firings} firings` : `unreadable (${copy.error ?? 'unknown error'})`}`);
490
+ }
491
+ lines.push(' Nothing reads these. Their history is recoverable only through a rebuild.');
492
+ }
493
+ lines.push(` event logs folded: ${report.eventLogs.length === 0 ? 'none' : report.eventLogs.map((l) => `${l.machineId} (${l.bytes} bytes)`).join(', ')}`);
494
+ for (const [machineId, lag] of Object.entries(report.checkpointLag)) {
495
+ lines.push(` checkpoint lag for ${machineId}: ${lag} bytes${lag > 0 ? ' behind the log head' : ''}`);
496
+ }
497
+ lines.push(report.compactionAdvised
498
+ ? ` log size ${mb(report.totalLogBytes)} is past the ${mb(report.compactionAdvisoryBytes)} point where a cold fold stops fitting the context-injection budget. Run \`learning-usage prune\` to compact it to the configured retention window.`
499
+ : ` log size ${mb(report.totalLogBytes)} of ${mb(report.compactionAdvisoryBytes)} before compaction is worth running.`);
500
+ if (report.rebuildAdvised)
501
+ lines.push('', REBUILD_NOTE);
502
+ lines.push('', 'Nothing here blocks anything. This is what the record can and cannot currently prove.');
503
+ return lines.join('\n');
504
+ });
505
+ });
397
506
  // ── root ─────────────────────────────────────────────────────────────────────
398
507
  exports.learningUsageCommand = new commander_1.Command('learning-usage')
399
508
  .description('Read and analyse the learning and rule usage record (issue #1103)')
@@ -405,4 +514,5 @@ exports.learningUsageCommand = new commander_1.Command('learning-usage')
405
514
  .addCommand(reportCommand)
406
515
  .addCommand(backfillCommand)
407
516
  .addCommand(stampIdsCommand)
408
- .addCommand(pruneCommand);
517
+ .addCommand(pruneCommand)
518
+ .addCommand(verifyCommand);
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveEventDir = resolveEventDir;
4
+ exports.resolveMachineId = resolveMachineId;
5
+ exports.appendEvents = appendEvents;
6
+ exports.listEventLogs = listEventLogs;
7
+ exports.readEventsSince = readEventsSince;
8
+ exports.compactEventLogs = compactEventLogs;
9
+ /**
10
+ * Issue #1752 — the durable half of the usage record.
11
+ *
12
+ * The store used to be one mutable JSON document rewritten by every writer with a
13
+ * plain `writeFileSync`. Measured (spike S1b): eight concurrent processes doing
14
+ * that on a *plain local disk*, no sync folder involved, lost 1589 of 1600
15
+ * writes. The four OneDrive conflict copies in issue #1752 are a symptom of the
16
+ * same defect, not its cause.
17
+ *
18
+ * So the durable artifact is an append-only log, one file per machine:
19
+ *
20
+ * OFFER/FIRING event → appendFileSync of one whole JSON line
21
+ * usage/learning-usage.json → a derived snapshot with a byte checkpoint
22
+ *
23
+ * Three measurements decided the shape, and each closed off an alternative:
24
+ *
25
+ * S2 16 processes x 1000 lines on the real OneDrive-synced path produced
26
+ * 16000/16000 complete parseable lines, 0 interleaved, 0 conflict copies.
27
+ * That is what removes the need for a same-machine lock. The per-machine
28
+ * filename is what removes the cross-machine question, because two
29
+ * machines then never write one file.
30
+ * S3 `renameSync` over a target any process holds open fails EPERM 100 times
31
+ * in 100 on Windows, on a synced path *and* a local one, because Node's
32
+ * `openSync(path,'r')` does not request FILE_SHARE_DELETE. The usage store
33
+ * is read by concurrent MCP processes by design, so rename-publish is not
34
+ * available here. Recorded so it is not re-proposed. See also #820.
35
+ * S4b A full fold is linear: 84 ms at today's volume, 794 ms at ten times it.
36
+ * A snapshot plus a tail fold is 2.6 ms at both 9.9 MB and 79 MB. Flat.
37
+ *
38
+ * This module owns the log files and nothing else: framing, appending, reading a
39
+ * tail from a byte offset, and compaction. It knows a line is a JSON object and
40
+ * knows nothing about what a usage record looks like. That is deliberate — the
41
+ * record shape lives in `learning-usage-store.ts`, which imports this, so there
42
+ * is no cycle between them.
43
+ *
44
+ * The same primitive is already used by `src/ai-hub/raw-event-log-store.ts`
45
+ * (append one JSON line, track byte offsets); this is the learning-usage
46
+ * equivalent rather than a second general mechanism.
47
+ */
48
+ const fs_1 = require("fs");
49
+ const os_1 = require("os");
50
+ const path_1 = require("path");
51
+ /** Subdirectory of the usage folder holding the per-machine logs. */
52
+ const EVENT_DIR_NAME = 'events';
53
+ const LOG_SUFFIX = '.jsonl';
54
+ /** The log directory that belongs to a snapshot: `<usage>/events/`. */
55
+ function resolveEventDir(snapshotPath) {
56
+ return (0, path_1.join)((0, path_1.dirname)(snapshotPath), EVENT_DIR_NAME);
57
+ }
58
+ /**
59
+ * The filename component identifying this machine. Two machines must never write
60
+ * one file, which is the entire cross-machine safety argument, so anything that
61
+ * is not a safe filename character is replaced rather than dropped (dropping can
62
+ * collide two different hostnames onto one name).
63
+ */
64
+ function resolveMachineId() {
65
+ const raw = (() => { try {
66
+ return (0, os_1.hostname)();
67
+ }
68
+ catch {
69
+ return '';
70
+ } })();
71
+ const safe = String(raw || 'unknown-machine').trim().replace(/[^A-Za-z0-9._-]/g, '_');
72
+ return safe || 'unknown-machine';
73
+ }
74
+ function logPathFor(eventDir, machineId) {
75
+ return (0, path_1.join)(eventDir, `${machineId}${LOG_SUFFIX}`);
76
+ }
77
+ /**
78
+ * Append one JSON line per record. No read, so there is no lost-update window to
79
+ * narrow: the window is gone rather than smaller.
80
+ */
81
+ function appendEvents(eventDir, records, machineId = resolveMachineId()) {
82
+ if (records.length === 0)
83
+ return 0;
84
+ (0, fs_1.mkdirSync)(eventDir, { recursive: true });
85
+ const logPath = logPathFor(eventDir, machineId);
86
+ const separator = endsOnARecordBoundary(logPath) ? '' : '\n';
87
+ const payload = `${separator}${records.map((record) => JSON.stringify(record)).join('\n')}\n`;
88
+ (0, fs_1.appendFileSync)(logPath, payload, 'utf8');
89
+ return records.length;
90
+ }
91
+ /**
92
+ * Does the log already end where a record ends?
93
+ *
94
+ * A process killed part-way through an append leaves a line with no terminator.
95
+ * Appending straight onto it concatenates the torn record and the new one into a
96
+ * single unparseable line, so one interrupted write costs two events rather than
97
+ * one. Found in the #1752 bug bash. Closing the boundary first means a torn write
98
+ * loses only itself.
99
+ *
100
+ * One byte read per append, and only when the log already exists.
101
+ */
102
+ function endsOnARecordBoundary(logPath) {
103
+ try {
104
+ const fd = (0, fs_1.openSync)(logPath, 'r');
105
+ try {
106
+ const size = (0, fs_1.fstatSync)(fd).size;
107
+ if (size === 0)
108
+ return true;
109
+ const last = Buffer.alloc(1);
110
+ (0, fs_1.readSync)(fd, last, 0, 1, size - 1);
111
+ return last[0] === 0x0a;
112
+ }
113
+ finally {
114
+ (0, fs_1.closeSync)(fd);
115
+ }
116
+ }
117
+ catch {
118
+ // No log yet, or it cannot be read. A fresh file starts on a boundary, and an
119
+ // unreadable one is the fold's problem rather than the append's.
120
+ return true;
121
+ }
122
+ }
123
+ function listEventLogs(eventDir) {
124
+ if (!(0, fs_1.existsSync)(eventDir))
125
+ return [];
126
+ let names;
127
+ try {
128
+ names = (0, fs_1.readdirSync)(eventDir);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const logs = [];
134
+ for (const name of names) {
135
+ if (!name.endsWith(LOG_SUFFIX))
136
+ continue;
137
+ const path = (0, path_1.join)(eventDir, name);
138
+ let size = 0;
139
+ try {
140
+ const fd = (0, fs_1.openSync)(path, 'r');
141
+ try {
142
+ size = (0, fs_1.fstatSync)(fd).size;
143
+ }
144
+ finally {
145
+ (0, fs_1.closeSync)(fd);
146
+ }
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size });
152
+ }
153
+ return logs.sort((a, b) => a.machineId.localeCompare(b.machineId));
154
+ }
155
+ function readTail(log, from) {
156
+ const restarted = from > log.size;
157
+ const start = restarted ? 0 : Math.max(0, from);
158
+ const length = log.size - start;
159
+ if (length <= 0) {
160
+ return { ...log, records: [], skippedLines: 0, through: start, restarted };
161
+ }
162
+ let text;
163
+ try {
164
+ const fd = (0, fs_1.openSync)(log.path, 'r');
165
+ try {
166
+ const buffer = Buffer.alloc(length);
167
+ const read = (0, fs_1.readSync)(fd, buffer, 0, length, start);
168
+ text = buffer.subarray(0, read).toString('utf8');
169
+ }
170
+ finally {
171
+ (0, fs_1.closeSync)(fd);
172
+ }
173
+ }
174
+ catch {
175
+ // Unreadable right now (locked, mid-sync). The checkpoint does not advance,
176
+ // so the next reader picks it up. Degrading to what is readable is the
177
+ // existing contract for this derived data.
178
+ return { ...log, records: [], skippedLines: 0, through: start, restarted };
179
+ }
180
+ // Only complete lines are consumed. A trailing partial is what an interrupted
181
+ // append leaves behind, and advancing past it would lose the record for good.
182
+ const lastNewline = text.lastIndexOf('\n');
183
+ if (lastNewline === -1) {
184
+ return { ...log, records: [], skippedLines: 0, through: start, restarted };
185
+ }
186
+ const complete = text.slice(0, lastNewline);
187
+ const partialBytes = Buffer.byteLength(text.slice(lastNewline + 1), 'utf8');
188
+ const records = [];
189
+ let skippedLines = 0;
190
+ for (const line of complete.split('\n')) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed)
193
+ continue;
194
+ try {
195
+ const parsed = JSON.parse(trimmed);
196
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
197
+ records.push(parsed);
198
+ else
199
+ skippedLines += 1;
200
+ }
201
+ catch {
202
+ skippedLines += 1;
203
+ }
204
+ }
205
+ return { ...log, records, skippedLines, through: log.size - partialBytes, restarted };
206
+ }
207
+ /**
208
+ * Read every log's tail past its checkpoint.
209
+ *
210
+ * A checkpoint past the end of its log means the log was compacted or replaced
211
+ * underneath the snapshot. `restarted` says so, and the caller discards the whole
212
+ * snapshot rather than returning a short count that looks like real data.
213
+ */
214
+ function readEventsSince(eventDir, checkpoints) {
215
+ const reads = listEventLogs(eventDir).map((log) => readTail(log, checkpoints[log.machineId] ?? 0));
216
+ return {
217
+ reads,
218
+ skippedLines: reads.reduce((sum, r) => sum + r.skippedLines, 0),
219
+ restarted: reads.some((r) => r.restarted),
220
+ };
221
+ }
222
+ /**
223
+ * Rewrite every log keeping only the records `keep` accepts.
224
+ *
225
+ * Required rather than an optimization: S4 measured a cold full fold growing
226
+ * linearly to 794 ms at ten times today's volume, and the checkpoint only makes a
227
+ * cold fold rare, not bounded.
228
+ *
229
+ * Published by writing a temp file and renaming it. That is safe here and not on
230
+ * the snapshot (S3), because nothing holds a compacted log open for reading in
231
+ * the way concurrent MCP processes hold the snapshot: this runs from the CLI, and
232
+ * a failed rename leaves the original log untouched.
233
+ */
234
+ function compactEventLogs(eventDir, keep) {
235
+ const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [] };
236
+ for (const log of listEventLogs(eventDir)) {
237
+ const read = readTail(log, 0);
238
+ result.scanned += read.records.length;
239
+ result.skippedLines += read.skippedLines;
240
+ const retained = read.records.filter(keep);
241
+ result.kept += retained.length;
242
+ result.dropped += read.records.length - retained.length;
243
+ if (retained.length === read.records.length && read.skippedLines === 0)
244
+ continue;
245
+ const temp = `${log.path}.compacting`;
246
+ try {
247
+ (0, fs_1.writeFileSync)(temp, retained.length ? `${retained.map((r) => JSON.stringify(r)).join('\n')}\n` : '', 'utf8');
248
+ (0, fs_1.renameSync)(temp, log.path);
249
+ }
250
+ catch {
251
+ // The log is authoritative and the original is still intact, so a failed
252
+ // compaction costs a slower cold fold and nothing else.
253
+ try {
254
+ (0, fs_1.rmSync)(temp, { force: true });
255
+ }
256
+ catch { /* best effort */ }
257
+ }
258
+ }
259
+ result.logs = listEventLogs(eventDir);
260
+ return result;
261
+ }
@@ -50,30 +50,34 @@ function buildUsageLookup(store) {
50
50
  /**
51
51
  * Resolve the usage for one entry from whichever handles it has. Prefers the id,
52
52
  * because a title is only a fallback for entries the id migration has not reached.
53
+ *
54
+ * Issue #1752 A1: the preference order is `resolveEntryIdentity`'s, not a second
55
+ * copy of it. The title key is still tried afterwards, because a record written
56
+ * before the entry was stamped is reachable only by title.
53
57
  */
54
58
  function lookupUsage(lookup, id, fileType, title) {
55
- if (id) {
56
- const byId = lookup.get(id);
57
- if (byId)
58
- return byId;
59
- }
60
- return lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title));
59
+ const identity = (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType, title });
60
+ const resolved = lookup.get(identity.key);
61
+ if (resolved)
62
+ return resolved;
63
+ return identity.source === 'id' ? lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title)) : undefined;
61
64
  }
62
65
  /**
63
- * The model with the most recent offer. Used as "the current model" for the
64
- * per-model retirement question, so it follows the agent in use rather than
65
- * needing to be configured.
66
+ * Issue #1752 C1 — "the current model" comes from the caller, never from the store.
67
+ *
68
+ * This used to return the model of the globally most-recent offer. The store is
69
+ * shared by concurrent sessions from two machines and two agents, so that reports
70
+ * whichever agent wrote last. On the sleep-on-learnings run of 2026-09-18 it
71
+ * answered `gpt-5` while the session was Claude Opus 5, and since retirement
72
+ * candidacy is computed per model, every per-model conclusion in that run was
73
+ * computed against a model that was not running.
74
+ *
75
+ * Resolution order is the explicit flag, then `FRAIM_ACTIVE_MODEL`, then null.
76
+ * That follows the existing `FRAIM_ACTIVE_USER_EMAIL` convention, which is
77
+ * read-only and operator-set, so it introduces no new configuration mechanism.
78
+ * Null is reported as `unknown` and suppresses every per-model conclusion rather
79
+ * than being computed against a null bucket.
66
80
  */
67
- function resolveCurrentModel(store) {
68
- let bestDate = '';
69
- let bestModel = null;
70
- for (const record of Object.values(store.entries)) {
71
- for (const entry of record.offered.log) {
72
- if (entry.model && entry.date >= bestDate) {
73
- bestDate = entry.date;
74
- bestModel = entry.model;
75
- }
76
- }
77
- }
78
- return bestModel;
81
+ function resolveCurrentModel(explicit) {
82
+ return (0, learning_usage_store_1.normalizeModelName)(explicit) ?? (0, learning_usage_store_1.normalizeModelName)(process.env.FRAIM_ACTIVE_MODEL);
79
83
  }