fraim 2.0.270 → 2.0.271

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,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractLegacyTitle = exports.parseFiringSection = void 0;
4
+ exports.recordAttestationsFromRetrospective = recordAttestationsFromRetrospective;
5
+ exports.runBackfill = runBackfill;
6
+ /**
7
+ * Issue #1103 — turning an attestation into a usage record.
8
+ *
9
+ * The retrospective is the durable record of a firing; the usage store is derived
10
+ * from it (R17). So there is exactly one parser (`learning-firing-parser.ts`) and
11
+ * one recorder here, used by two entry points: the live attestation at the end of
12
+ * a job, and the backfill over the retrospectives already on disk (R15).
13
+ *
14
+ * Matching is an id or an exact title, and nothing else. R5 forbids resolving a
15
+ * prose reference to the closest title, and an unmatched item is reported rather
16
+ * than guessed at: a wrong match writes a firing onto the wrong entry, which is
17
+ * worse than no firing at all.
18
+ */
19
+ const fs_1 = require("fs");
20
+ const path_1 = require("path");
21
+ const learning_usage_store_1 = require("./learning-usage-store");
22
+ const learning_firing_parser_1 = require("./learning-firing-parser");
23
+ const learning_context_builder_1 = require("./learning-context-builder");
24
+ // Re-exported so callers that only need one of the two halves keep one import.
25
+ var learning_firing_parser_2 = require("./learning-firing-parser");
26
+ Object.defineProperty(exports, "parseFiringSection", { enumerable: true, get: function () { return learning_firing_parser_2.parseFiringSection; } });
27
+ Object.defineProperty(exports, "extractLegacyTitle", { enumerable: true, get: function () { return learning_firing_parser_2.extractLegacyTitle; } });
28
+ function buildEntryIndex(workspaceRoot, userId) {
29
+ const byId = new Map();
30
+ const byTitle = new Map();
31
+ // Every scope a firing could name: the manager's own entries, the org entries
32
+ // the agent also reads, and the manager-coaching family.
33
+ for (const scope of ['manager', 'org', 'reverse']) {
34
+ let entries = [];
35
+ try {
36
+ entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope);
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ for (const entry of entries) {
42
+ if (entry.id && !byId.has(entry.id))
43
+ byId.set(entry.id, entry);
44
+ const key = (0, learning_usage_store_1.normalizeEntryTitle)(entry.title);
45
+ if (!byTitle.has(key))
46
+ byTitle.set(key, entry);
47
+ }
48
+ }
49
+ return { byId, byTitle };
50
+ }
51
+ function fileTypeOf(entry) {
52
+ return learning_context_builder_1.CATEGORY_TO_FILETYPE[entry.category] ?? 'mistake-patterns';
53
+ }
54
+ function retrospectiveDate(content, fallback) {
55
+ const match = content.match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
56
+ return match ? match[1] : fallback;
57
+ }
58
+ /**
59
+ * Record the firings a retrospective attests. Idempotent per (entry, source), so a
60
+ * re-run, or a backfill that overlaps a live attestation, cannot inflate a count.
61
+ */
62
+ function recordAttestationsFromRetrospective(workspaceRoot, userId, options) {
63
+ const limits = options.limits ?? (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
64
+ const parsed = (0, learning_firing_parser_1.parseFiringSection)(options.content, { source: options.source, legacy: options.legacy });
65
+ const date = options.date ?? retrospectiveDate(options.content, new Date().toISOString().slice(0, 10));
66
+ const index = buildEntryIndex(workspaceRoot, userId);
67
+ const unmatched = [];
68
+ const inputs = [];
69
+ for (const item of parsed.items) {
70
+ let entry;
71
+ if (item.id) {
72
+ entry = index.byId.get(item.id);
73
+ if (!entry) {
74
+ unmatched.push({
75
+ id: item.id,
76
+ title: item.title,
77
+ reason: `No entry carries the id ${item.id}. Reported unmatched rather than assigned to the closest title.`,
78
+ raw: item.raw,
79
+ });
80
+ continue;
81
+ }
82
+ }
83
+ else {
84
+ entry = index.byTitle.get((0, learning_usage_store_1.normalizeEntryTitle)(item.title));
85
+ if (!entry) {
86
+ unmatched.push({
87
+ id: null,
88
+ title: item.title,
89
+ reason: 'No entry has exactly this title. Exact titles match; a paraphrase is reported rather than resolved by similarity.',
90
+ raw: item.raw,
91
+ });
92
+ continue;
93
+ }
94
+ }
95
+ const fileType = fileTypeOf(entry);
96
+ inputs.push({
97
+ key: entry.id ?? (0, learning_usage_store_1.titleUsageKey)(fileType, entry.title),
98
+ family: 'learning',
99
+ title: entry.title,
100
+ fileType,
101
+ level: entry.level,
102
+ outcome: item.outcome,
103
+ note: item.note,
104
+ job: options.job,
105
+ date,
106
+ agent: options.agent,
107
+ model: options.model,
108
+ source: item.format === 'legacy' ? 'backfill-legacy' : 'attested',
109
+ sourceRef: options.source,
110
+ });
111
+ }
112
+ let applied = 0;
113
+ let alreadyRecorded = 0;
114
+ if (inputs.length > 0) {
115
+ const result = (0, learning_usage_store_1.recordFirings)(inputs, { limits, now: options.now, storePath: options.storePath });
116
+ applied = result.recorded;
117
+ alreadyRecorded = result.alreadyRecorded;
118
+ }
119
+ return {
120
+ source: options.source,
121
+ sectionState: parsed.sectionState,
122
+ itemsRead: parsed.items.length + parsed.rejected.length,
123
+ applied,
124
+ alreadyRecorded,
125
+ unmatched,
126
+ rejected: parsed.rejected,
127
+ };
128
+ }
129
+ function jobFromFrontmatter(content) {
130
+ const match = content.match(/^job:\s*(.+)$/m);
131
+ return match ? match[1].trim() : 'unknown';
132
+ }
133
+ /**
134
+ * Read every retrospective and apply the firings it attests.
135
+ *
136
+ * Best effort by design (R15): only an id or an exact title resolves, and the
137
+ * unmatched count is reported rather than resolved by fuzzy matching. A low match
138
+ * rate is an expected outcome to state, not a failure to hide — only a small
139
+ * fraction of the existing attestations name a machine-matchable identifier.
140
+ *
141
+ * A retrospective already stamped `synthesized` is still read: being synthesized
142
+ * for learning content is not the same as having contributed usage data.
143
+ */
144
+ function runBackfill(workspaceRoot, userId, options) {
145
+ const limits = options.limits ?? (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
146
+ const dir = options.retrospectivesDir ?? (0, path_1.join)(workspaceRoot, 'docs', 'retrospectives');
147
+ const report = {
148
+ filesScanned: 0, filesWithSection: 0, itemsRead: 0, applied: 0,
149
+ alreadyRecorded: 0, unmatched: [], rejected: [], synthesizedFilesRead: 0,
150
+ };
151
+ if (!(0, fs_1.existsSync)(dir))
152
+ return report;
153
+ let files;
154
+ try {
155
+ files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.md'));
156
+ }
157
+ catch {
158
+ return report;
159
+ }
160
+ for (const file of files) {
161
+ let content;
162
+ try {
163
+ content = (0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf8');
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ report.filesScanned += 1;
169
+ if (/^synthesized:\s*\S/m.test(content))
170
+ report.synthesizedFilesRead += 1;
171
+ const result = recordAttestationsFromRetrospective(workspaceRoot, userId, {
172
+ content,
173
+ source: `docs/retrospectives/${file}`,
174
+ job: jobFromFrontmatter(content),
175
+ agent: options.agent,
176
+ model: options.model,
177
+ legacy: true,
178
+ limits,
179
+ now: options.now,
180
+ storePath: options.storePath,
181
+ });
182
+ if (result.sectionState !== 'absent')
183
+ report.filesWithSection += 1;
184
+ report.itemsRead += result.itemsRead;
185
+ report.applied += result.applied;
186
+ report.alreadyRecorded += result.alreadyRecorded;
187
+ report.unmatched.push(...result.unmatched);
188
+ report.rejected.push(...result.rejected);
189
+ }
190
+ return report;
191
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildUsageLookup = buildUsageLookup;
4
+ exports.lookupUsage = lookupUsage;
5
+ exports.resolveCurrentModel = resolveCurrentModel;
6
+ /**
7
+ * Issue #1103 — projecting the usage record into the shapes its readers need.
8
+ *
9
+ * The store owns writing and persistence; this owns turning what is stored into
10
+ * what a scorer, a surface, or a report asks for. Split out because those are two
11
+ * jobs with different reasons to change: the record's schema changes when the
12
+ * privacy or retention contract changes, and these projections change when a
13
+ * consumer needs a different view.
14
+ *
15
+ * Nothing here writes.
16
+ */
17
+ const learning_usage_store_1 = require("./learning-usage-store");
18
+ function toLookupValue(record) {
19
+ return {
20
+ key: record.key,
21
+ offered: record.offered.total,
22
+ offeredAboveThreshold: record.offered.aboveThreshold,
23
+ fired: record.fired.byOutcome.prevented + record.fired.byOutcome.applied,
24
+ firedTotalAttestations: record.fired.total,
25
+ lastFired: record.fired.lastFiredAt,
26
+ lastOffered: record.offered.lastOfferedAt,
27
+ firedUnder: Object.keys(record.fired.byModel),
28
+ byOutcome: record.fired.byOutcome,
29
+ log: record.fired.log,
30
+ };
31
+ }
32
+ /**
33
+ * A lookup keyed by every identifier that resolves to a record: the entry id, and
34
+ * every title the entry has been known by. That is what lets a scorer look up an
35
+ * entry by whichever handle it has.
36
+ */
37
+ function buildUsageLookup(store) {
38
+ const lookup = new Map();
39
+ for (const record of Object.values(store.entries)) {
40
+ const value = toLookupValue(record);
41
+ lookup.set(record.key, value);
42
+ if (record.fileType) {
43
+ for (const title of record.titles) {
44
+ lookup.set((0, learning_usage_store_1.titleUsageKey)(record.fileType, title), value);
45
+ }
46
+ }
47
+ }
48
+ return lookup;
49
+ }
50
+ /**
51
+ * Resolve the usage for one entry from whichever handles it has. Prefers the id,
52
+ * because a title is only a fallback for entries the id migration has not reached.
53
+ */
54
+ 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));
61
+ }
62
+ /**
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
+ */
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;
79
+ }
@@ -0,0 +1,417 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
4
+ exports.resolveUsageStorePath = resolveUsageStorePath;
5
+ exports.resolveUsageLimits = resolveUsageLimits;
6
+ exports.normalizeEntryTitle = normalizeEntryTitle;
7
+ exports.titleUsageKey = titleUsageKey;
8
+ exports.ruleUsageKey = ruleUsageKey;
9
+ exports.readUsageStore = readUsageStore;
10
+ exports.sanitizeNote = sanitizeNote;
11
+ exports.recordOffers = recordOffers;
12
+ exports.recordFirings = recordFirings;
13
+ exports.noteTitleChange = noteTitleChange;
14
+ exports.pruneUsageStore = pruneUsageStore;
15
+ /**
16
+ * Issue #1103 — the usage record for learnings and rules.
17
+ *
18
+ * Two sides of one boundary, because only one of them is observable:
19
+ *
20
+ * OFFERED — written by the runtime with no agent involvement. The proxy composes
21
+ * the injected context block, so it knows which files it listed and,
22
+ * for the score-gated families, which entries in them were above
23
+ * threshold. That is what an offer means here.
24
+ * FIRED — attested by the agent at retrospective time. FRAIM cannot observe a
25
+ * file read, and a read is not a use, so influence has to be claimed
26
+ * rather than inferred.
27
+ *
28
+ * The record is metadata only (R16): entry identity, outcome, date, job, agent,
29
+ * model. Never prompts, diffs, or work content. Notes are reduced to one bounded
30
+ * line so a multi-line diff cannot be smuggled in through the attestation.
31
+ *
32
+ * Aggregate counters are kept in full; the detailed logs are bounded (R16a), on
33
+ * Mem0's precedent of retaining the last 20 accesses rather than an unbounded
34
+ * behavioural history.
35
+ *
36
+ * The store lives inside the manager layer's content root, which is the layer the
37
+ * existing GDPR guardrail blocks from a git backend without an explicit override
38
+ * (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
39
+ * second guardrail here.
40
+ *
41
+ * It is derived data (R17): deleting it loses no lesson, because every firing is
42
+ * still written in the retrospective that attested it and every offer is
43
+ * re-recorded the next time the entry is delivered.
44
+ */
45
+ const fs_1 = require("fs");
46
+ const path_1 = require("path");
47
+ const pack_home_1 = require("../cli/utils/pack-home");
48
+ const artifact_retention_cleanup_1 = require("./artifact-retention-cleanup");
49
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
50
+ // ── Vocabulary ───────────────────────────────────────────────────────────────
51
+ /**
52
+ * The five outcomes (spec: Outcome vocabulary). `ignored` and `not-offered` are
53
+ * the two that change what the manager should do: the first says fix the entry,
54
+ * the second says fix the delivery.
55
+ */
56
+ exports.FIRING_OUTCOMES = ['prevented', 'applied', 'not-applicable', 'ignored', 'not-offered'];
57
+ /**
58
+ * R8: only these advance the decay clock. An entry that was offered, read, and
59
+ * correctly not relevant has not proved anything about its usefulness.
60
+ */
61
+ exports.CLOCK_ADVANCING_OUTCOMES = ['prevented', 'applied'];
62
+ const STORE_VERSION = 1;
63
+ const STORE_FILE_NAME = 'learning-usage.json';
64
+ /** Default bounds. The firing bound follows Mem0's published 20-access retention. */
65
+ const DEFAULT_FIRING_LOG_LIMIT = 20;
66
+ /**
67
+ * The offer log is larger than the firing log because R7 has to answer "was this
68
+ * entry in context for that job" across a plausible recurrence window, which
69
+ * needs per-job offer detail rather than a counter.
70
+ */
71
+ const DEFAULT_OFFER_LOG_LIMIT = 50;
72
+ /** Open Question 2: configurable and greater than a handful. */
73
+ const DEFAULT_RETIREMENT_OFFER_THRESHOLD = 10;
74
+ /** R16: one line, bounded, no fences. Long enough for a sentence, short enough to be useless as a transcript. */
75
+ const MAX_NOTE_LENGTH = 240;
76
+ // ── Paths and config ─────────────────────────────────────────────────────────
77
+ /**
78
+ * The store path, inside the manager layer so the existing git-backend guardrail
79
+ * covers it. `resolvePackHome` never touches the network and never throws.
80
+ */
81
+ function resolveUsageStorePath() {
82
+ return (0, path_1.join)((0, pack_home_1.resolvePackHome)('manager').contentRoot, 'usage', STORE_FILE_NAME);
83
+ }
84
+ function readWorkspaceConfig(workspaceRoot) {
85
+ try {
86
+ const configPath = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
87
+ if (!(0, fs_1.existsSync)(configPath))
88
+ return {};
89
+ return JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
95
+ function positiveInt(value, fallback) {
96
+ return Number.isInteger(value) && value > 0 ? value : fallback;
97
+ }
98
+ function resolveUsageLimits(workspaceRoot) {
99
+ const usage = readWorkspaceConfig(workspaceRoot)?.learning?.usage;
100
+ const block = usage && typeof usage === 'object' && !Array.isArray(usage) ? usage : {};
101
+ let retentionDays = -1;
102
+ try {
103
+ retentionDays = (0, artifact_retention_cleanup_1.resolveArtifactRetentionConfig)(workspaceRoot).values.learning_usage;
104
+ }
105
+ catch {
106
+ // Retention config is advisory here; a malformed config must not stop a write.
107
+ }
108
+ return {
109
+ firingLogLimit: positiveInt(block.firingLogLimit, DEFAULT_FIRING_LOG_LIMIT),
110
+ offerLogLimit: positiveInt(block.offerLogLimit, DEFAULT_OFFER_LOG_LIMIT),
111
+ retirementOfferThreshold: positiveInt(block.retirementOfferThreshold, DEFAULT_RETIREMENT_OFFER_THRESHOLD),
112
+ retentionDays: typeof retentionDays === 'number' ? retentionDays : -1,
113
+ };
114
+ }
115
+ // ── Keys ─────────────────────────────────────────────────────────────────────
116
+ /** Normalize a title for keying. Case and whitespace only: never a similarity measure (D4). */
117
+ function normalizeEntryTitle(title) {
118
+ return title.trim().replace(/\s+/g, ' ').toLowerCase();
119
+ }
120
+ /**
121
+ * The fallback key for an entry that carries no `**Id**` line yet, so the feature
122
+ * works before the id migration has run. Namespaced by family so the same title
123
+ * in two families is two entries.
124
+ */
125
+ function titleUsageKey(fileType, title) {
126
+ return `T:${fileType}:${normalizeEntryTitle(title)}`;
127
+ }
128
+ /** The key for a rule file, namespaced so it can never collide with a learning id. */
129
+ function ruleUsageKey(displayPath) {
130
+ return `R:${displayPath.replace(/\\/g, '/')}`;
131
+ }
132
+ // ── Read and write ───────────────────────────────────────────────────────────
133
+ function emptyStore() {
134
+ return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {} };
135
+ }
136
+ /**
137
+ * Is this parsed value a record every consumer can read without crashing?
138
+ *
139
+ * The shallow `record.offered && record.fired` this replaced was not enough. A
140
+ * record whose sub-objects exist but are empty passed it and then threw in
141
+ * `buildUsageReport` (`record.offered.log is not iterable`),
142
+ * `findRetirementCandidates` and `deriveStanding`, so `fraim learning-usage
143
+ * report` and `candidates` failed outright instead of degrading. That state is
144
+ * reachable: the store is a JSON file inside the manager home, which is commonly
145
+ * a synced folder, so an interrupted write or a partial sync produces valid JSON
146
+ * with a hollow record.
147
+ *
148
+ * Checks only what a consumer actually dereferences, so a record carrying an
149
+ * unknown extra field is still accepted and a future field addition does not
150
+ * silently start discarding history.
151
+ */
152
+ function isUsableRecord(value) {
153
+ if (!value || typeof value !== 'object')
154
+ return false;
155
+ const record = value;
156
+ const offered = record.offered;
157
+ const fired = record.fired;
158
+ if (!offered || typeof offered !== 'object' || !fired || typeof fired !== 'object')
159
+ return false;
160
+ return Array.isArray(offered.log)
161
+ && typeof offered.total === 'number'
162
+ && typeof offered.aboveThreshold === 'number'
163
+ && !!offered.byModel && typeof offered.byModel === 'object'
164
+ && Array.isArray(fired.log)
165
+ && typeof fired.total === 'number'
166
+ && !!fired.byOutcome && typeof fired.byOutcome === 'object'
167
+ && Array.isArray(record.titles);
168
+ }
169
+ /**
170
+ * A missing or corrupt store reads as empty. It is derived data, so losing it is
171
+ * recoverable and must never break the loader that reads it.
172
+ */
173
+ function readUsageStore(storePath = resolveUsageStorePath()) {
174
+ try {
175
+ if (!(0, fs_1.existsSync)(storePath))
176
+ return emptyStore();
177
+ const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
178
+ // `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
179
+ // Object.values yields whatever the array held and every consumer that reads
180
+ // `entry.offered` throws on it.
181
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries
182
+ || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
183
+ return emptyStore();
184
+ }
185
+ // Drop any value that is not a usable record rather than handing a caller
186
+ // something it will crash on. A partially corrupt store degrades to the records
187
+ // that are still readable, which is the right behaviour for derived data.
188
+ const entries = {};
189
+ for (const [key, value] of Object.entries(parsed.entries)) {
190
+ if (isUsableRecord(value))
191
+ entries[key] = value;
192
+ }
193
+ return { version: parsed.version ?? STORE_VERSION, updatedAt: parsed.updatedAt ?? emptyStore().updatedAt, entries };
194
+ }
195
+ catch {
196
+ return emptyStore();
197
+ }
198
+ }
199
+ function writeUsageStore(store, storePath = resolveUsageStorePath()) {
200
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
201
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
202
+ }
203
+ // ── Mutation helpers ─────────────────────────────────────────────────────────
204
+ function emptyOutcomeCounts() {
205
+ return { prevented: 0, applied: 0, 'not-applicable': 0, ignored: 0, 'not-offered': 0 };
206
+ }
207
+ function ensureRecord(store, key, family, fileType, level) {
208
+ const existing = store.entries[key];
209
+ if (existing) {
210
+ if (fileType && !existing.fileType)
211
+ existing.fileType = fileType;
212
+ if (level && !existing.level)
213
+ existing.level = level;
214
+ return existing;
215
+ }
216
+ const created = {
217
+ key,
218
+ family,
219
+ titles: [],
220
+ fileType: fileType ?? null,
221
+ level: level ?? null,
222
+ offered: { total: 0, aboveThreshold: 0, byModel: {}, lastOfferedAt: null, log: [] },
223
+ fired: {
224
+ total: 0, byOutcome: emptyOutcomeCounts(), byModel: {},
225
+ firstFiredAt: null, lastFiredAt: null, lastAttestedAt: null, log: [], seenSources: [],
226
+ },
227
+ };
228
+ store.entries[key] = created;
229
+ return created;
230
+ }
231
+ function rememberTitle(record, title) {
232
+ if (!title)
233
+ return;
234
+ const trimmed = title.trim();
235
+ if (!trimmed)
236
+ return;
237
+ const normalized = normalizeEntryTitle(trimmed);
238
+ const already = record.titles.findIndex((t) => normalizeEntryTitle(t) === normalized);
239
+ // Already the most recent title: nothing to do. Guard the empty case explicitly,
240
+ // because findIndex returns -1 and length - 1 is also -1 on an empty list.
241
+ if (record.titles.length > 0 && already === record.titles.length - 1)
242
+ return;
243
+ if (already >= 0)
244
+ record.titles.splice(already, 1);
245
+ record.titles.push(trimmed);
246
+ }
247
+ /**
248
+ * R16: reduce an attested note to a single bounded line. This is the control that
249
+ * keeps a diff, a prompt, or a transcript out of a behavioural record.
250
+ */
251
+ function sanitizeNote(note) {
252
+ const oneLine = String(note ?? '')
253
+ .replace(/```+/g, ' ')
254
+ .replace(/[\r\n\t]+/g, ' ')
255
+ .replace(/\s+/g, ' ')
256
+ .trim();
257
+ return oneLine.length > MAX_NOTE_LENGTH ? `${oneLine.slice(0, MAX_NOTE_LENGTH - 1).trimEnd()}…` : oneLine;
258
+ }
259
+ function maxDate(a, b) {
260
+ if (!a)
261
+ return b;
262
+ return b > a ? b : a;
263
+ }
264
+ function minDate(a, b) {
265
+ if (!a)
266
+ return b;
267
+ return b < a ? b : a;
268
+ }
269
+ function isValidDate(value) {
270
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(value).getTime());
271
+ }
272
+ function withinRetention(date, retentionDays, now) {
273
+ if (retentionDays < 0)
274
+ return true;
275
+ const parsed = new Date(date).getTime();
276
+ if (Number.isNaN(parsed))
277
+ return true;
278
+ return (now.getTime() - parsed) / 86_400_000 <= retentionDays;
279
+ }
280
+ /**
281
+ * Trim one entry's logs: drop records outside the retention window, then keep the
282
+ * newest N. Aggregates are never touched, so R16a's second criterion holds.
283
+ */
284
+ function trimLogs(record, limits, now) {
285
+ const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
286
+ record.offered.log = record.offered.log
287
+ .filter((r) => withinRetention(r.date, limits.retentionDays, now))
288
+ .sort(byDate)
289
+ .slice(-limits.offerLogLimit);
290
+ record.fired.log = record.fired.log
291
+ .filter((r) => withinRetention(r.date, limits.retentionDays, now))
292
+ .sort(byDate)
293
+ .slice(-limits.firingLogLimit);
294
+ }
295
+ /**
296
+ * R1 and R2: write one offer record per listed entry, without any agent
297
+ * involvement and without depending on the agent reading the file.
298
+ */
299
+ function recordOffers(offers, options) {
300
+ const storePath = options.storePath ?? resolveUsageStorePath();
301
+ const now = options.now ?? new Date();
302
+ const store = readUsageStore(storePath);
303
+ let recorded = 0;
304
+ for (const offer of offers) {
305
+ if (!offer.key || !isValidDate(offer.date))
306
+ continue;
307
+ const record = ensureRecord(store, offer.key, offer.family, offer.fileType, offer.level);
308
+ rememberTitle(record, offer.title);
309
+ record.offered.total += 1;
310
+ if (offer.aboveThreshold)
311
+ record.offered.aboveThreshold += 1;
312
+ if (offer.model)
313
+ record.offered.byModel[offer.model] = (record.offered.byModel[offer.model] ?? 0) + 1;
314
+ record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, offer.date);
315
+ record.offered.log.push({
316
+ date: offer.date,
317
+ job: offer.job,
318
+ agent: offer.agent ?? null,
319
+ model: offer.model ?? null,
320
+ aboveThreshold: Boolean(offer.aboveThreshold),
321
+ });
322
+ trimLogs(record, options.limits, now);
323
+ recorded += 1;
324
+ }
325
+ if (recorded > 0) {
326
+ store.updatedAt = now.toISOString();
327
+ writeUsageStore(store, storePath);
328
+ }
329
+ return { recorded, entries: Object.keys(store.entries).length };
330
+ }
331
+ /**
332
+ * R4 and R6: write the attested firings. Idempotent per (entry, attestation
333
+ * source) so a re-run, or a backfill that overlaps a live attestation, cannot
334
+ * inflate a count.
335
+ */
336
+ function recordFirings(firings, options) {
337
+ const storePath = options.storePath ?? resolveUsageStorePath();
338
+ const now = options.now ?? new Date();
339
+ const store = readUsageStore(storePath);
340
+ let recorded = 0;
341
+ let alreadyRecorded = 0;
342
+ for (const firing of firings) {
343
+ if (!exports.FIRING_OUTCOMES.includes(firing.outcome)) {
344
+ throw new Error(`Unknown firing outcome "${firing.outcome}". Use one of: ${exports.FIRING_OUTCOMES.join(', ')}.`);
345
+ }
346
+ if (!firing.key || !isValidDate(firing.date))
347
+ continue;
348
+ const record = ensureRecord(store, firing.key, firing.family, firing.fileType, firing.level);
349
+ if (firing.sourceRef && record.fired.seenSources.includes(firing.sourceRef)) {
350
+ alreadyRecorded += 1;
351
+ continue;
352
+ }
353
+ rememberTitle(record, firing.title);
354
+ record.fired.total += 1;
355
+ record.fired.byOutcome[firing.outcome] += 1;
356
+ record.fired.lastAttestedAt = maxDate(record.fired.lastAttestedAt, firing.date);
357
+ if (exports.CLOCK_ADVANCING_OUTCOMES.includes(firing.outcome)) {
358
+ record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, firing.date);
359
+ record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, firing.date);
360
+ if (firing.model)
361
+ record.fired.byModel[firing.model] = (record.fired.byModel[firing.model] ?? 0) + 1;
362
+ }
363
+ record.fired.log.push({
364
+ date: firing.date,
365
+ outcome: firing.outcome,
366
+ job: firing.job,
367
+ agent: firing.agent ?? null,
368
+ model: firing.model ?? null,
369
+ note: sanitizeNote(firing.note),
370
+ source: firing.source ?? 'attested',
371
+ });
372
+ if (firing.sourceRef)
373
+ record.fired.seenSources.push(firing.sourceRef);
374
+ trimLogs(record, options.limits, now);
375
+ recorded += 1;
376
+ }
377
+ if (recorded > 0 || alreadyRecorded > 0) {
378
+ store.updatedAt = now.toISOString();
379
+ writeUsageStore(store, storePath);
380
+ }
381
+ return { recorded, alreadyRecorded };
382
+ }
383
+ /**
384
+ * R5: record that an entry's title changed, so a firing written before the edit
385
+ * still resolves to it. Identity lives on the entry; this keeps the store's title
386
+ * index able to follow a rename.
387
+ */
388
+ function noteTitleChange(key, newTitle, options) {
389
+ const storePath = options.storePath ?? resolveUsageStorePath();
390
+ const store = readUsageStore(storePath);
391
+ const record = store.entries[key];
392
+ if (!record)
393
+ return;
394
+ rememberTitle(record, newTitle);
395
+ store.updatedAt = (options.now ?? new Date()).toISOString();
396
+ writeUsageStore(store, storePath);
397
+ }
398
+ /** Apply retention and log bounds across the whole store. Reports what it dropped. */
399
+ function pruneUsageStore(limits, options = {}) {
400
+ const storePath = options.storePath ?? resolveUsageStorePath();
401
+ const now = options.now ?? new Date();
402
+ const store = readUsageStore(storePath);
403
+ let prunedOfferRecords = 0;
404
+ let prunedFiringRecords = 0;
405
+ for (const record of Object.values(store.entries)) {
406
+ const offersBefore = record.offered.log.length;
407
+ const firingsBefore = record.fired.log.length;
408
+ trimLogs(record, limits, now);
409
+ prunedOfferRecords += offersBefore - record.offered.log.length;
410
+ prunedFiringRecords += firingsBefore - record.fired.log.length;
411
+ }
412
+ if (prunedOfferRecords > 0 || prunedFiringRecords > 0) {
413
+ store.updatedAt = now.toISOString();
414
+ writeUsageStore(store, storePath);
415
+ }
416
+ return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length };
417
+ }