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.
- package/dist/registry/scripts/learning/completion-state.js +153 -0
- package/dist/src/cli/commands/cleanup-artifacts.js +2 -2
- package/dist/src/core/ai-mentor.js +1 -1
- package/dist/src/core/handoff-contracts.js +43 -16
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +145 -27
- package/dist/src/local-mcp-server/learning-usage-analysis.js +79 -2
- package/dist/src/local-mcp-server/learning-usage-attestation.js +9 -2
- package/dist/src/local-mcp-server/learning-usage-command.js +127 -22
- package/dist/src/local-mcp-server/learning-usage-events.js +267 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +626 -88
- package/dist/src/local-mcp-server/learning-usage-verify.js +207 -0
- package/dist/src/local-mcp-server/stdio-server.js +37 -3
- package/package.json +2 -1
|
@@ -18,6 +18,13 @@ 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
|
+
* Preserve checkpointed history and distinguish backup recovery from attestation backfill.
|
|
25
|
+
* Backfill supplements older firings; it cannot reconstruct missing offers.
|
|
26
|
+
*/
|
|
27
|
+
const REBUILD_NOTE = "Keep `learning-usage.json`, its `.checkpoint` backup, `events/` and any conflict copies. After consolidation, the snapshot and checkpoint backup preserve lifetime counters and already-counted source markers; the remaining journal cannot rebuild that history. A missing or torn snapshot is read from its checkpoint backup automatically. Restore matching checkpoint and journal files from backup if both copies are damaged. Inspect split identities and conflict copies before repairing them; do not delete the snapshot to clear a warning. Separately, `learning-usage backfill` can recover older firings from retrospectives; raw learnings and retrospectives cannot restore runtime offers.";
|
|
21
28
|
function resolveWorkspaceRoot(options) {
|
|
22
29
|
return node_path_1.default.resolve(options.workspaceRoot || options.root || process.cwd());
|
|
23
30
|
}
|
|
@@ -38,6 +45,23 @@ function addCommonOptions(command) {
|
|
|
38
45
|
.option('--user <email>', 'Active user email')
|
|
39
46
|
.option('--json', 'Print JSON');
|
|
40
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Issue #1752 C1: every command that draws a per-model conclusion takes the model
|
|
50
|
+
* from its caller. It is never inferred from the record, because the record is
|
|
51
|
+
* shared by concurrent sessions from more than one agent on more than one
|
|
52
|
+
* machine, so inference reports whichever wrote last.
|
|
53
|
+
*/
|
|
54
|
+
function addModelOption(command) {
|
|
55
|
+
return command.option('--model <model>', 'The model you are running under. Falls back to FRAIM_ACTIVE_MODEL, then reports unknown');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Issue #1752 B: the offer and firing log bounds are applied when the event log is
|
|
59
|
+
* folded, so a read states the bounds it wants rather than inheriting whatever the
|
|
60
|
+
* last writer used.
|
|
61
|
+
*/
|
|
62
|
+
function readStore(workspaceRoot) {
|
|
63
|
+
return (0, learning_usage_store_1.readUsageStore)(undefined, { limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot) });
|
|
64
|
+
}
|
|
41
65
|
// ── offers ───────────────────────────────────────────────────────────────────
|
|
42
66
|
const offersCommand = addCommonOptions(new commander_1.Command('offers'))
|
|
43
67
|
.description('List the learning entries and rule files the runtime delivered, so an attestation is written against the record rather than memory')
|
|
@@ -52,7 +76,7 @@ const offersCommand = addCommonOptions(new commander_1.Command('offers'))
|
|
|
52
76
|
// actually delivered. Re-deriving from the files on disk answers a subtly
|
|
53
77
|
// different question — what would be delivered now — and the two diverge if a
|
|
54
78
|
// learning file changed during the job.
|
|
55
|
-
const store = (
|
|
79
|
+
const store = readStore(workspaceRoot);
|
|
56
80
|
const recorded = Object.values(store.entries)
|
|
57
81
|
.filter((entry) => entry.offered.log.some((offer) => offer.job === options.job))
|
|
58
82
|
.map((entry) => {
|
|
@@ -102,6 +126,7 @@ const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-fi
|
|
|
102
126
|
.option('--agent <name>', 'Agent that attested')
|
|
103
127
|
.option('--model <model>', 'Model that attested')
|
|
104
128
|
.option('--legacy', 'Also accept the pre-#1103 prose form')
|
|
129
|
+
.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
130
|
.action((options) => {
|
|
106
131
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
107
132
|
const user = resolveUser(options);
|
|
@@ -118,6 +143,7 @@ const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-fi
|
|
|
118
143
|
agent: options.agent ?? null,
|
|
119
144
|
model: options.model ?? null,
|
|
120
145
|
legacy: Boolean(options.legacy),
|
|
146
|
+
integrityMode: options.force ? 'flag' : 'throw',
|
|
121
147
|
});
|
|
122
148
|
emit(result, options.json, () => {
|
|
123
149
|
const lines = [
|
|
@@ -151,17 +177,17 @@ const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
|
|
|
151
177
|
// character, so an agent assembling `T:<family>:<title>` into a shell command
|
|
152
178
|
// would be interpolating untrusted text into a command line. Passing the title
|
|
153
179
|
// as its own argument keeps it a single argv entry.
|
|
154
|
-
|
|
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 {
|
|
180
|
+
if (!options.entry && !(options.title && options.family)) {
|
|
162
181
|
throw new Error('Pass --entry <id>, or --title <title> together with --family <family>.');
|
|
163
182
|
}
|
|
164
|
-
|
|
183
|
+
// #1752 A1: the same identity function the offer and firing paths use, so
|
|
184
|
+
// the CLI cannot ask about a key neither of them would have written.
|
|
185
|
+
const key = (0, learning_usage_store_1.resolveEntryIdentity)({
|
|
186
|
+
id: options.entry ? String(options.entry) : null,
|
|
187
|
+
fileType: options.family ? String(options.family) : null,
|
|
188
|
+
title: options.title ? String(options.title) : null,
|
|
189
|
+
}).key;
|
|
190
|
+
const store = readStore(workspaceRoot);
|
|
165
191
|
const diagnosis = (0, learning_usage_analysis_1.classifyRecurrence)(store, {
|
|
166
192
|
key,
|
|
167
193
|
job: options.job,
|
|
@@ -177,14 +203,13 @@ const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
|
|
|
177
203
|
].join('\n'));
|
|
178
204
|
});
|
|
179
205
|
// ── candidates ───────────────────────────────────────────────────────────────
|
|
180
|
-
const candidatesCommand = addCommonOptions(new commander_1.Command('candidates'))
|
|
206
|
+
const candidatesCommand = addModelOption(addCommonOptions(new commander_1.Command('candidates')))
|
|
181
207
|
.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
208
|
.option('--detector <path>', 'Path to the corpus hygiene detector that provides the structural signal')
|
|
184
209
|
.action((options) => {
|
|
185
210
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
186
211
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
187
|
-
const result = (0, learning_usage_analysis_1.findRetirementCandidates)((
|
|
212
|
+
const result = (0, learning_usage_analysis_1.findRetirementCandidates)(readStore(workspaceRoot), {
|
|
188
213
|
workspaceRoot,
|
|
189
214
|
limits,
|
|
190
215
|
currentModel: options.model ?? undefined,
|
|
@@ -192,7 +217,14 @@ const candidatesCommand = addCommonOptions(new commander_1.Command('candidates')
|
|
|
192
217
|
});
|
|
193
218
|
emit(result, options.json, () => {
|
|
194
219
|
if (result.candidates.length === 0) {
|
|
195
|
-
|
|
220
|
+
const lines = [`No retirement candidates. Current model: ${result.currentModel ?? 'unknown'}; threshold ${result.offerThreshold} offers.`];
|
|
221
|
+
if (result.reason)
|
|
222
|
+
lines.push('', result.reason);
|
|
223
|
+
const unknownAttribution = result.notCandidates.filter((c) => /attribution is unknown/i.test(c.reason));
|
|
224
|
+
if (unknownAttribution.length > 0) {
|
|
225
|
+
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.`);
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n');
|
|
196
228
|
}
|
|
197
229
|
const lines = [`Current model: ${result.currentModel ?? 'unknown'}. Threshold: ${result.offerThreshold} offers.`, ''];
|
|
198
230
|
for (const c of result.candidates) {
|
|
@@ -203,13 +235,13 @@ const candidatesCommand = addCommonOptions(new commander_1.Command('candidates')
|
|
|
203
235
|
});
|
|
204
236
|
});
|
|
205
237
|
// ── standing ─────────────────────────────────────────────────────────────────
|
|
206
|
-
const standingCommand = addCommonOptions(new commander_1.Command('standing'))
|
|
238
|
+
const standingCommand = addModelOption(addCommonOptions(new commander_1.Command('standing')))
|
|
207
239
|
.description('Assess each entry as promoted, standard or demoted from its usage record, and list the ones whose standing should change')
|
|
208
240
|
.option('--changes-only', 'Print only the entries whose standing should change')
|
|
209
241
|
.action((options) => {
|
|
210
242
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
211
243
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
212
|
-
const report = (0, learning_usage_analysis_1.assessStanding)((
|
|
244
|
+
const report = (0, learning_usage_analysis_1.assessStanding)(readStore(workspaceRoot), { limits });
|
|
213
245
|
const payload = options.changesOnly ? { ...report, assessments: report.changes } : report;
|
|
214
246
|
emit(payload, options.json, () => {
|
|
215
247
|
const rows = (options.changesOnly ? report.changes : report.assessments);
|
|
@@ -231,16 +263,17 @@ const standingCommand = addCommonOptions(new commander_1.Command('standing'))
|
|
|
231
263
|
});
|
|
232
264
|
});
|
|
233
265
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
234
|
-
const reportCommand = addCommonOptions(new commander_1.Command('report'))
|
|
266
|
+
const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('report')))
|
|
235
267
|
.description('Answer which learnings are still earning their place, from the record rather than from memory')
|
|
236
268
|
.option('--window <days>', 'Reporting window', '90')
|
|
237
269
|
.action((options) => {
|
|
238
270
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
239
271
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
240
|
-
const report = (0, learning_usage_analysis_1.buildUsageReport)((
|
|
272
|
+
const report = (0, learning_usage_analysis_1.buildUsageReport)(readStore(workspaceRoot), {
|
|
241
273
|
workspaceRoot,
|
|
242
274
|
limits,
|
|
243
275
|
windowDays: Number.parseInt(options.window, 10) || 90,
|
|
276
|
+
currentModel: options.model ?? undefined,
|
|
244
277
|
});
|
|
245
278
|
emit(report, options.json, () => {
|
|
246
279
|
const lines = [
|
|
@@ -256,7 +289,13 @@ const reportCommand = addCommonOptions(new commander_1.Command('report'))
|
|
|
256
289
|
}
|
|
257
290
|
if (report.ignoredWhileInContext.length) {
|
|
258
291
|
lines.push('', 'Ignored while in context');
|
|
259
|
-
|
|
292
|
+
// #1752 defect 8: this section selects on the `ignored` count, so it
|
|
293
|
+
// prints that count. Printing only the clock-advancing one made the
|
|
294
|
+
// section contradict its own evidence column.
|
|
295
|
+
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.`));
|
|
296
|
+
}
|
|
297
|
+
if (report.integrityFlagged > 0) {
|
|
298
|
+
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
299
|
}
|
|
261
300
|
lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
|
|
262
301
|
return lines.join('\n');
|
|
@@ -343,6 +382,19 @@ function stampFile(filePath, fileType, apply) {
|
|
|
343
382
|
return { file: filePath, entriesStamped: stamped, entriesAlreadyStamped: alreadyStamped };
|
|
344
383
|
}
|
|
345
384
|
const FILE_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
|
|
385
|
+
function defaultStampDirs(workspaceRoot) {
|
|
386
|
+
const dirs = [node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings')];
|
|
387
|
+
for (const level of ['org', 'manager']) {
|
|
388
|
+
try {
|
|
389
|
+
dirs.push(node_path_1.default.join((0, pack_home_1.resolvePackHome)(level).contentRoot, 'learnings'));
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// A pack home that does not resolve is simply not stamped. Reported by the
|
|
393
|
+
// filesScanned count rather than failing the command.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return [...new Set(dirs)];
|
|
397
|
+
}
|
|
346
398
|
function fileTypeFromName(fileName) {
|
|
347
399
|
for (const type of FILE_TYPES) {
|
|
348
400
|
if (fileName.includes(type))
|
|
@@ -356,9 +408,14 @@ const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
|
|
|
356
408
|
.option('--apply', 'Write the changes. Without this flag the command reports what it would do')
|
|
357
409
|
.action((options) => {
|
|
358
410
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
411
|
+
// #1752 A3: the default set is every tier the context reader merges, not
|
|
412
|
+
// the repo alone. `stamp-ids` never reaching the org and manager pack homes
|
|
413
|
+
// is half of defect 4: one lesson stamped in one tier and unstamped in the
|
|
414
|
+
// other resolved to two usage keys. Still opt-in behind --apply, and still a
|
|
415
|
+
// format migration that inserts one identifier line and touches no lesson text.
|
|
359
416
|
const dirs = options.dir.length
|
|
360
417
|
? options.dir.map((d) => node_path_1.default.resolve(d))
|
|
361
|
-
:
|
|
418
|
+
: defaultStampDirs(workspaceRoot);
|
|
362
419
|
const results = [];
|
|
363
420
|
for (const dir of dirs) {
|
|
364
421
|
if (!node_fs_1.default.existsSync(dir))
|
|
@@ -380,7 +437,8 @@ const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
|
|
|
380
437
|
files: results,
|
|
381
438
|
};
|
|
382
439
|
emit(payload, options.json, () => [
|
|
383
|
-
`${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files
|
|
440
|
+
`${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files in ${dirs.length} director${dirs.length === 1 ? 'y' : 'ies'}:`,
|
|
441
|
+
...dirs.map((d) => ` ${d}`),
|
|
384
442
|
`${payload.entriesAlreadyStamped} entries already carry an id.`,
|
|
385
443
|
payload.apply ? '' : 'Re-run with --apply to write the changes.',
|
|
386
444
|
].filter(Boolean).join('\n'));
|
|
@@ -394,6 +452,52 @@ const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
|
|
|
394
452
|
const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
|
|
395
453
|
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
454
|
});
|
|
455
|
+
function mb(bytes) {
|
|
456
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
457
|
+
}
|
|
458
|
+
// ── verify ───────────────────────────────────────────────────────────────────
|
|
459
|
+
const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('verify')))
|
|
460
|
+
.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')
|
|
461
|
+
.action((options) => {
|
|
462
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
463
|
+
const report = (0, learning_usage_verify_1.buildVerifyReport)({
|
|
464
|
+
snapshotPath: (0, learning_usage_store_1.resolveUsageStorePath)(),
|
|
465
|
+
limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot),
|
|
466
|
+
currentModel: options.model ?? undefined,
|
|
467
|
+
});
|
|
468
|
+
emit(report, options.json, () => {
|
|
469
|
+
const lines = [
|
|
470
|
+
`Usage record: ${report.entries} entries at ${report.snapshotPath}`,
|
|
471
|
+
`Current model: ${report.currentModel ?? 'unknown'}`,
|
|
472
|
+
'',
|
|
473
|
+
];
|
|
474
|
+
const finding = (count, singular, plural, remedy) => count === 0 ? ` none: ${singular}` : ` ${count} ${count === 1 ? singular : plural}. ${remedy}`;
|
|
475
|
+
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
|
|
476
|
+
? ` ${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.`
|
|
477
|
+
: ' 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');
|
|
478
|
+
if (report.storeCopies.length === 0) {
|
|
479
|
+
lines.push(' one store file, no unmerged copies');
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
lines.push(` ${report.storeCopies.length} unmerged store cop${report.storeCopies.length === 1 ? 'y' : 'ies'} beside the canonical snapshot:`);
|
|
483
|
+
for (const copy of report.storeCopies) {
|
|
484
|
+
lines.push(` ${copy.path}: ${copy.readable ? `${copy.entries} entries, ${copy.offers} offers, ${copy.firings} firings` : `unreadable (${copy.error ?? 'unknown error'})`}`);
|
|
485
|
+
}
|
|
486
|
+
lines.push(' Nothing reads these. Preserve them and the checkpoint backup for inspection; do not delete snapshots to clear this warning.');
|
|
487
|
+
}
|
|
488
|
+
lines.push(` event logs folded: ${report.eventLogs.length === 0 ? 'none' : report.eventLogs.map((l) => `${l.machineId} (${l.bytes} bytes)`).join(', ')}`);
|
|
489
|
+
for (const [machineId, lag] of Object.entries(report.checkpointLag)) {
|
|
490
|
+
lines.push(` checkpoint lag for ${machineId}: ${lag} bytes${lag > 0 ? ' behind the log head' : ''}`);
|
|
491
|
+
}
|
|
492
|
+
lines.push(report.compactionAdvised
|
|
493
|
+
? ` 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 consolidate processed events into the snapshot and checkpoint backup, then clear them independently of retention.`
|
|
494
|
+
: ` log size ${mb(report.totalLogBytes)} of ${mb(report.compactionAdvisoryBytes)} before compaction is worth running.`);
|
|
495
|
+
if (report.rebuildAdvised)
|
|
496
|
+
lines.push('', REBUILD_NOTE);
|
|
497
|
+
lines.push('', 'Nothing here blocks anything. This is what the record can and cannot currently prove.');
|
|
498
|
+
return lines.join('\n');
|
|
499
|
+
});
|
|
500
|
+
});
|
|
397
501
|
// ── root ─────────────────────────────────────────────────────────────────────
|
|
398
502
|
exports.learningUsageCommand = new commander_1.Command('learning-usage')
|
|
399
503
|
.description('Read and analyse the learning and rule usage record (issue #1103)')
|
|
@@ -405,4 +509,5 @@ exports.learningUsageCommand = new commander_1.Command('learning-usage')
|
|
|
405
509
|
.addCommand(reportCommand)
|
|
406
510
|
.addCommand(backfillCommand)
|
|
407
511
|
.addCommand(stampIdsCommand)
|
|
408
|
-
.addCommand(pruneCommand)
|
|
512
|
+
.addCommand(pruneCommand)
|
|
513
|
+
.addCommand(verifyCommand);
|
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
* Per-machine usage journals: job runs append complete JSON lines; sleep folds
|
|
11
|
+
* them into a recoverable snapshot and removes processed records.
|
|
12
|
+
*
|
|
13
|
+
* A cleared journal contains only {_usageJournalThroughBytes:N}. This framing
|
|
14
|
+
* marker preserves its logical position across atomic replacement, so a crash
|
|
15
|
+
* between checkpoint publication and journal cleanup cannot reset offsets or
|
|
16
|
+
* count an event twice. It contains no activity details. New appends continue
|
|
17
|
+
* from N; an older/missing snapshot cannot silently cold-fold compacted history.
|
|
18
|
+
*
|
|
19
|
+
* Maintenance requires no concurrent writers or sync activity. Ordinary writers
|
|
20
|
+
* append one payload; different machines use different filenames.
|
|
21
|
+
*/
|
|
22
|
+
const fs_1 = require("fs");
|
|
23
|
+
const os_1 = require("os");
|
|
24
|
+
const path_1 = require("path");
|
|
25
|
+
/** Subdirectory of the usage folder holding the per-machine logs. */
|
|
26
|
+
const EVENT_DIR_NAME = 'events';
|
|
27
|
+
const LOG_SUFFIX = '.jsonl';
|
|
28
|
+
const CHECKPOINT_MARKER = '_usageJournalThroughBytes';
|
|
29
|
+
/** The log directory that belongs to a snapshot: `<usage>/events/`. */
|
|
30
|
+
function resolveEventDir(snapshotPath) {
|
|
31
|
+
return (0, path_1.join)((0, path_1.dirname)(snapshotPath), EVENT_DIR_NAME);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The filename component identifying this machine. Two machines must never write
|
|
35
|
+
* one file, which is the entire cross-machine safety argument, so anything that
|
|
36
|
+
* is not a safe filename character is replaced rather than dropped (dropping can
|
|
37
|
+
* collide two different hostnames onto one name).
|
|
38
|
+
*/
|
|
39
|
+
function resolveMachineId() {
|
|
40
|
+
const raw = (() => { try {
|
|
41
|
+
return (0, os_1.hostname)();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return '';
|
|
45
|
+
} })();
|
|
46
|
+
const safe = String(raw || 'unknown-machine').trim().replace(/[^A-Za-z0-9._-]/g, '_');
|
|
47
|
+
return safe || 'unknown-machine';
|
|
48
|
+
}
|
|
49
|
+
function logPathFor(eventDir, machineId) {
|
|
50
|
+
return (0, path_1.join)(eventDir, `${machineId}${LOG_SUFFIX}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Append one JSON line per record. No read, so there is no lost-update window to
|
|
54
|
+
* narrow: the window is gone rather than smaller.
|
|
55
|
+
*/
|
|
56
|
+
function appendEvents(eventDir, records, machineId = resolveMachineId()) {
|
|
57
|
+
if (records.length === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
(0, fs_1.mkdirSync)(eventDir, { recursive: true });
|
|
60
|
+
const logPath = logPathFor(eventDir, machineId);
|
|
61
|
+
const separator = endsOnARecordBoundary(logPath) ? '' : '\n';
|
|
62
|
+
const payload = `${separator}${records.map((record) => JSON.stringify(record)).join('\n')}\n`;
|
|
63
|
+
(0, fs_1.appendFileSync)(logPath, payload, 'utf8');
|
|
64
|
+
return records.length;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Does the log already end where a record ends?
|
|
68
|
+
*
|
|
69
|
+
* A process killed part-way through an append leaves a line with no terminator.
|
|
70
|
+
* Appending straight onto it concatenates the torn record and the new one into a
|
|
71
|
+
* single unparseable line, so one interrupted write costs two events rather than
|
|
72
|
+
* one. Found in the #1752 bug bash. Closing the boundary first means a torn write
|
|
73
|
+
* loses only itself.
|
|
74
|
+
*
|
|
75
|
+
* One byte read per append, and only when the log already exists.
|
|
76
|
+
*/
|
|
77
|
+
function endsOnARecordBoundary(logPath) {
|
|
78
|
+
try {
|
|
79
|
+
const fd = (0, fs_1.openSync)(logPath, 'r');
|
|
80
|
+
try {
|
|
81
|
+
const size = (0, fs_1.fstatSync)(fd).size;
|
|
82
|
+
if (size === 0)
|
|
83
|
+
return true;
|
|
84
|
+
const last = Buffer.alloc(1);
|
|
85
|
+
(0, fs_1.readSync)(fd, last, 0, 1, size - 1);
|
|
86
|
+
return last[0] === 0x0a;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
(0, fs_1.closeSync)(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// No log yet, or it cannot be read. A fresh file starts on a boundary, and an
|
|
94
|
+
// unreadable one is the fold's problem rather than the append's.
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function listEventLogs(eventDir) {
|
|
99
|
+
if (!(0, fs_1.existsSync)(eventDir))
|
|
100
|
+
return [];
|
|
101
|
+
let names;
|
|
102
|
+
try {
|
|
103
|
+
names = (0, fs_1.readdirSync)(eventDir);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const logs = [];
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
if (!name.endsWith(LOG_SUFFIX))
|
|
111
|
+
continue;
|
|
112
|
+
const path = (0, path_1.join)(eventDir, name);
|
|
113
|
+
let size = 0;
|
|
114
|
+
let baseOffset = 0;
|
|
115
|
+
let headerBytes = 0;
|
|
116
|
+
try {
|
|
117
|
+
const fd = (0, fs_1.openSync)(path, 'r');
|
|
118
|
+
try {
|
|
119
|
+
size = (0, fs_1.fstatSync)(fd).size;
|
|
120
|
+
const prefix = Buffer.alloc(Math.min(size, 128));
|
|
121
|
+
(0, fs_1.readSync)(fd, prefix, 0, prefix.length, 0);
|
|
122
|
+
const newline = prefix.indexOf(0x0a);
|
|
123
|
+
if (newline >= 0) {
|
|
124
|
+
try {
|
|
125
|
+
const header = JSON.parse(prefix.subarray(0, newline).toString('utf8'));
|
|
126
|
+
if (Object.keys(header).length === 1 && Number.isSafeInteger(header[CHECKPOINT_MARKER]) && header[CHECKPOINT_MARKER] >= 0) {
|
|
127
|
+
baseOffset = header[CHECKPOINT_MARKER];
|
|
128
|
+
headerBytes = newline + 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* An ordinary first event, not a checkpoint marker. */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
(0, fs_1.closeSync)(fd);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size, baseOffset, headerBytes });
|
|
142
|
+
}
|
|
143
|
+
return logs.sort((a, b) => a.machineId.localeCompare(b.machineId));
|
|
144
|
+
}
|
|
145
|
+
function readTail(log, from) {
|
|
146
|
+
const end = log.baseOffset + log.size - log.headerBytes;
|
|
147
|
+
if (from < log.baseOffset)
|
|
148
|
+
throw new Error('Usage checkpoint is missing or older than the consolidated journal. Restore learning-usage.json or its .checkpoint backup; raw retrospectives cannot restore runtime offers.');
|
|
149
|
+
const restarted = from > end;
|
|
150
|
+
if (restarted && log.baseOffset > 0)
|
|
151
|
+
throw new Error('Usage journal is behind its checkpoint; restore matching files before continuing.');
|
|
152
|
+
const start = restarted ? 0 : Math.max(0, from);
|
|
153
|
+
const physicalStart = start - log.baseOffset + log.headerBytes;
|
|
154
|
+
const length = log.size - physicalStart;
|
|
155
|
+
if (length <= 0) {
|
|
156
|
+
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
157
|
+
}
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
const fd = (0, fs_1.openSync)(log.path, 'r');
|
|
161
|
+
try {
|
|
162
|
+
const buffer = Buffer.alloc(length);
|
|
163
|
+
const read = (0, fs_1.readSync)(fd, buffer, 0, length, physicalStart);
|
|
164
|
+
text = buffer.subarray(0, read).toString('utf8');
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
(0, fs_1.closeSync)(fd);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Unreadable right now (locked, mid-sync). The checkpoint does not advance,
|
|
172
|
+
// so the next reader picks it up. Degrading to what is readable is the
|
|
173
|
+
// existing contract for this derived data.
|
|
174
|
+
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
175
|
+
}
|
|
176
|
+
// Only complete lines are consumed. A trailing partial is what an interrupted
|
|
177
|
+
// append leaves behind, and advancing past it would lose the record for good.
|
|
178
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
179
|
+
if (lastNewline === -1) {
|
|
180
|
+
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
181
|
+
}
|
|
182
|
+
const complete = text.slice(0, lastNewline);
|
|
183
|
+
const partialBytes = Buffer.byteLength(text.slice(lastNewline + 1), 'utf8');
|
|
184
|
+
const records = [];
|
|
185
|
+
let skippedLines = 0;
|
|
186
|
+
for (const line of complete.split('\n')) {
|
|
187
|
+
const trimmed = line.trim();
|
|
188
|
+
if (!trimmed)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(trimmed);
|
|
192
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
193
|
+
records.push(parsed);
|
|
194
|
+
else
|
|
195
|
+
skippedLines += 1;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
skippedLines += 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ...log, records, skippedLines, through: end - partialBytes, restarted };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Read every log's tail past its checkpoint.
|
|
205
|
+
*
|
|
206
|
+
* A checkpoint past the end of its log means the log was compacted or replaced
|
|
207
|
+
* underneath the snapshot. `restarted` says so, and the caller discards the whole
|
|
208
|
+
* snapshot rather than returning a short count that looks like real data.
|
|
209
|
+
*/
|
|
210
|
+
function readEventsSince(eventDir, checkpoints) {
|
|
211
|
+
const reads = listEventLogs(eventDir).map((log) => readTail(log, checkpoints[log.machineId] ?? 0));
|
|
212
|
+
return {
|
|
213
|
+
reads,
|
|
214
|
+
skippedLines: reads.reduce((sum, r) => sum + r.skippedLines, 0),
|
|
215
|
+
restarted: reads.some((r) => r.restarted),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Clear fully checkpointed logs whose records are all understood by this reader.
|
|
220
|
+
* Preserve unknown, malformed, partial or newly appended logs byte-for-byte.
|
|
221
|
+
* Flush then atomically replace each log with its logical-position header; the
|
|
222
|
+
* checkpoint saved before this call remains correct on either side of a crash.
|
|
223
|
+
*/
|
|
224
|
+
function compactEventLogs(eventDir, keep, checkpoints) {
|
|
225
|
+
const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [], checkpoints: { ...checkpoints } };
|
|
226
|
+
for (const log of listEventLogs(eventDir)) {
|
|
227
|
+
const read = readTail(log, log.baseOffset);
|
|
228
|
+
result.scanned += read.records.length;
|
|
229
|
+
result.skippedLines += read.skippedLines;
|
|
230
|
+
result.kept += read.records.length;
|
|
231
|
+
// The snapshot knows nothing about a later append or torn
|
|
232
|
+
// tail. Leave that log and its old checkpoint alone for the next fold.
|
|
233
|
+
if (read.through !== log.baseOffset + log.size - log.headerBytes
|
|
234
|
+
|| read.through !== (checkpoints[log.machineId] ?? 0) || read.skippedLines > 0)
|
|
235
|
+
continue;
|
|
236
|
+
const retained = read.records.filter(keep);
|
|
237
|
+
// Unknown records may belong to a newer reader. Leave their whole log
|
|
238
|
+
// intact so their original positions remain meaningful.
|
|
239
|
+
if (retained.length > 0 || read.records.length === 0)
|
|
240
|
+
continue;
|
|
241
|
+
const temp = `${log.path}.compacting`;
|
|
242
|
+
try {
|
|
243
|
+
const payload = `${JSON.stringify({ [CHECKPOINT_MARKER]: read.through })}\n`;
|
|
244
|
+
(0, fs_1.writeFileSync)(temp, payload, { encoding: 'utf8', flush: true });
|
|
245
|
+
if ((0, fs_1.statSync)(log.path).size !== log.size) {
|
|
246
|
+
(0, fs_1.rmSync)(temp, { force: true });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
(0, fs_1.renameSync)(temp, log.path);
|
|
250
|
+
const dropped = read.records.length - retained.length;
|
|
251
|
+
result.kept -= dropped;
|
|
252
|
+
result.dropped += dropped;
|
|
253
|
+
// The logical checkpoint remains unchanged across the atomic replacement.
|
|
254
|
+
result.checkpoints[log.machineId] = read.through;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// The log is authoritative and the original is still intact, so a failed
|
|
258
|
+
// compaction costs a slower cold fold and nothing else.
|
|
259
|
+
try {
|
|
260
|
+
(0, fs_1.rmSync)(temp, { force: true });
|
|
261
|
+
}
|
|
262
|
+
catch { /* best effort */ }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
result.logs = listEventLogs(eventDir);
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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(
|
|
68
|
-
|
|
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
|
}
|