@holdyourvoice/hyv 3.2.0 → 3.3.0

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.
Files changed (50) hide show
  1. package/Readme.md +23 -10
  2. package/dist/ai-editor-rules.js +5 -2
  3. package/dist/ai-editor.js +52 -9
  4. package/dist/ai-editor.test.js +62 -10
  5. package/dist/approval-capability.js +111 -0
  6. package/dist/approval-capability.test.js +52 -0
  7. package/dist/approval-context.js +54 -0
  8. package/dist/approval-context.test.js +38 -0
  9. package/dist/benchmark.js +232 -0
  10. package/dist/benchmark.test.js +328 -0
  11. package/dist/canonical-json.js +123 -0
  12. package/dist/canonical-json.test.js +24 -0
  13. package/dist/cli.js +272 -19
  14. package/dist/cli.test.js +205 -8
  15. package/dist/hygiene.js +6 -0
  16. package/dist/hygiene.test.js +7 -1
  17. package/dist/judgment-task.js +171 -0
  18. package/dist/judgment-task.test.js +162 -0
  19. package/dist/learning.js +240 -100
  20. package/dist/learning.test.js +203 -3
  21. package/dist/lifecycle-adapter.js +75 -0
  22. package/dist/lifecycle-adapter.test.js +56 -0
  23. package/dist/mcp-tools.js +101 -7
  24. package/dist/mcp-tools.test.js +156 -6
  25. package/dist/mcp.js +213 -6
  26. package/dist/mcp.test.js +210 -11
  27. package/dist/pipeline.js +78 -14
  28. package/dist/pipeline.test.js +36 -2
  29. package/dist/preservation.js +89 -0
  30. package/dist/preservation.test.js +22 -0
  31. package/dist/profile.js +87 -0
  32. package/dist/profile.test.js +114 -0
  33. package/dist/rebuild-task.js +226 -0
  34. package/dist/rebuild-task.test.js +179 -0
  35. package/dist/release-audit.test.js +111 -2
  36. package/dist/rewrite-task.js +136 -16
  37. package/dist/rewrite-task.test.js +62 -7
  38. package/dist/rule-reconciliation.test.js +50 -0
  39. package/dist/semantic-review.js +176 -7
  40. package/dist/semantic-review.test.js +98 -14
  41. package/dist/stage1-dry-run.test.js +39 -0
  42. package/dist/stage1-evaluation.js +579 -0
  43. package/dist/stage1-evaluation.test.js +184 -0
  44. package/dist/stage1-human-packet.test.js +102 -0
  45. package/dist/stage1-schema-contract.test.js +95 -0
  46. package/dist/stage2-human-packet.test.js +81 -0
  47. package/dist/version.js +1 -1
  48. package/dist/voice-dna.js +53 -1
  49. package/dist/voice-dna.test.js +79 -1
  50. package/package.json +2 -2
package/dist/learning.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createHash } from 'node:crypto';
2
- import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  const MAX_PREFERENCES = 10;
@@ -20,118 +20,185 @@ function canonicalJson(value) {
20
20
  export function profileFingerprint(profile) {
21
21
  return createHash('sha256').update(canonicalJson(profile)).digest('hex');
22
22
  }
23
- function learningDirectory(options = {}) {
24
- return join(options.root ?? process.env.HYV_HOME ?? join(homedir(), '.hyv'), 'learning');
25
- }
26
- function eventFile(profile, options = {}) {
27
- return join(learningDirectory(options), `${profileFingerprint(profile)}.jsonl`);
28
- }
29
- function normalizeInstruction(instruction) {
30
- return instruction.replace(/\s+/g, ' ').trim();
31
- }
23
+ function identity(profile) { return profile.version === '3' ? profile.id : profileFingerprint(profile); }
24
+ function revision(profile) { return profile.version === '3' ? profile.revision : 1; }
25
+ function revisionDigest(profile) { return profile.version === '3' ? profile.revisionDigest : profileFingerprint(profile); }
26
+ function learningDirectory(options = {}) { return join(options.root ?? process.env.HYV_HOME ?? join(homedir(), '.hyv'), 'learning'); }
27
+ function eventFile(profile, options = {}) { return join(learningDirectory(options), `${identity(profile)}.jsonl`); }
28
+ function normalizeInstruction(instruction) { return instruction.replace(/\s+/g, ' ').trim(); }
29
+ function digest(value) { return createHash('sha256').update(canonicalJson(value)).digest('hex'); }
32
30
  function isResolvedFinding(value) {
33
31
  if (!value || typeof value !== 'object')
34
32
  return false;
35
33
  const finding = value;
36
- return (finding.engine === 'voice_dna' || finding.engine === 'ai_editor')
37
- && typeof finding.id === 'string' && finding.id.length > 0
38
- && (finding.severity === 'red' || finding.severity === 'yellow')
39
- && Number.isInteger(finding.count) && (finding.count ?? 0) > 0;
34
+ return (finding.engine === 'voice_dna' || finding.engine === 'ai_editor') && typeof finding.id === 'string' && finding.id.length > 0
35
+ && (finding.severity === 'red' || finding.severity === 'yellow') && Number.isInteger(finding.count) && (finding.count ?? 0) > 0;
40
36
  }
41
- function parseEvent(line) {
37
+ function parseEvent(line, lineNumber = 0) {
42
38
  try {
43
- const event = JSON.parse(line);
44
- if (!event || event.version !== '1' || typeof event.timestamp !== 'string')
39
+ const parsed = JSON.parse(line);
40
+ if (parsed.version === '1' && typeof parsed.timestamp === 'string') {
41
+ const legacyId = digest({ version: '1', timestamp: parsed.timestamp, kind: parsed.kind, lineNumber });
42
+ const base = {
43
+ version: '2', eventId: legacyId, mutationId: `legacy:${legacyId}`, timestamp: parsed.timestamp,
44
+ profileRevision: 1, revisionDigest: 'legacy-v1', authority: 'system', provenance: 'hyv-3.2.0', weight: 1,
45
+ compatibility: 'same-or-newer', kind: parsed.kind, requestDigest: legacyId,
46
+ };
47
+ if (parsed.kind === 'instruction') {
48
+ const instruction = normalizeInstruction(typeof parsed.instruction === 'string' ? parsed.instruction : '');
49
+ return instruction && instruction.length <= MAX_INSTRUCTION_CHARACTERS ? { ...base, instruction } : undefined;
50
+ }
51
+ if (parsed.kind === 'verified_candidate' && Array.isArray(parsed.resolved) && parsed.resolved.length > 0 && parsed.resolved.every(isResolvedFinding) && typeof parsed.outcome === 'string') {
52
+ return { ...base, resolved: parsed.resolved, outcome: parsed.outcome };
53
+ }
45
54
  return undefined;
46
- if (event.kind === 'instruction' && typeof event.instruction === 'string') {
47
- const instruction = normalizeInstruction(event.instruction);
48
- if (instruction.length > 0 && instruction.length <= MAX_INSTRUCTION_CHARACTERS)
49
- return { version: '1', timestamp: event.timestamp, kind: 'instruction', instruction };
50
55
  }
51
- const resolved = event.resolved;
52
- if (event.kind === 'verified_candidate' && Array.isArray(resolved) && resolved.length > 0 && resolved.every(isResolvedFinding) && typeof event.outcome === 'string') {
53
- return { version: '1', timestamp: event.timestamp, kind: 'verified_candidate', resolved, outcome: event.outcome };
56
+ const value = parsed;
57
+ if (value.version !== '2' || typeof value.eventId !== 'string' || typeof value.mutationId !== 'string' || typeof value.requestDigest !== 'string' || typeof value.timestamp !== 'string'
58
+ || !Number.isSafeInteger(value.profileRevision) || (value.profileRevision ?? 0) < 1 || typeof value.revisionDigest !== 'string'
59
+ || !['founder', 'team', 'system'].includes(value.authority ?? '') || typeof value.provenance !== 'string'
60
+ || typeof value.weight !== 'number' || !Number.isFinite(value.weight) || value.weight <= 0
61
+ || !['same-or-newer', 'exact'].includes(value.compatibility ?? '')
62
+ || !['verified_candidate', 'instruction', 'ratification', 'supersession', 'migration'].includes(value.kind ?? ''))
63
+ return undefined;
64
+ if (value.kind === 'instruction') {
65
+ const instruction = normalizeInstruction(value.instruction ?? '');
66
+ if (!instruction || instruction.length > MAX_INSTRUCTION_CHARACTERS)
67
+ return undefined;
68
+ value.instruction = instruction;
54
69
  }
70
+ if (value.kind === 'verified_candidate' && (!Array.isArray(value.resolved) || !value.resolved.length || !value.resolved.every(isResolvedFinding) || typeof value.outcome !== 'string'))
71
+ return undefined;
72
+ if ((value.kind === 'ratification' || value.kind === 'supersession') && typeof value.targetEventId !== 'string')
73
+ return undefined;
74
+ return value;
55
75
  }
56
76
  catch {
57
77
  return undefined;
58
78
  }
59
- return undefined;
60
79
  }
61
- function readRecentText(file) {
80
+ function readEventsFromFile(file) {
81
+ let contents;
62
82
  try {
63
- const size = statSync(file).size;
64
- const length = Math.min(size, MAX_STORAGE_BYTES);
65
- const descriptor = openSync(file, 'r');
66
- try {
67
- const buffer = Buffer.alloc(length);
68
- readSync(descriptor, buffer, 0, length, Math.max(0, size - length));
69
- return buffer.toString('utf8');
70
- }
71
- finally {
72
- closeSync(descriptor);
73
- }
83
+ contents = readFileSync(file, 'utf8');
74
84
  }
75
- catch {
76
- return '';
85
+ catch (error) {
86
+ if (error.code === 'ENOENT')
87
+ return [];
88
+ throw error;
77
89
  }
90
+ return contents.split('\n').flatMap((line, index) => {
91
+ if (!line.trim())
92
+ return [];
93
+ const event = parseEvent(line, index + 1);
94
+ if (!event)
95
+ throw new Error(`Learning store is corrupt at line ${index + 1}.`);
96
+ return [event];
97
+ });
78
98
  }
79
- function readEvents(profile, options = {}) {
80
- const file = eventFile(profile, options);
81
- return readRecentText(file).split('\n').flatMap((line) => {
82
- const event = parseEvent(line);
83
- return event ? [event] : [];
84
- }).slice(-MAX_EVENTS);
99
+ function readEvents(profile, options = {}) { return readEventsFromFile(eventFile(profile, options)); }
100
+ function serialize(events) { return events.length ? `${events.map((event) => JSON.stringify(event)).join('\n')}\n` : ''; }
101
+ function learningState(events, profile) {
102
+ return {
103
+ superseded: new Set(events.filter((event) => event.kind === 'supersession').map((event) => event.targetEventId)),
104
+ ratified: new Set(events.filter((event) => event.kind === 'ratification' && event.profileRevision <= revision(profile)).map((event) => event.targetEventId)),
105
+ };
85
106
  }
86
- function serialize(events) {
87
- return `${events.map((event) => JSON.stringify(event)).join('\n')}\n`;
107
+ function isCompatible(event, profile) {
108
+ return event.profileRevision <= revision(profile)
109
+ && (event.compatibility === 'same-or-newer' || event.profileRevision === revision(profile));
88
110
  }
89
- function withCompactionLock(file, operation) {
111
+ function withLock(file, operation) {
90
112
  const lock = `${file}.lock`;
91
113
  for (let attempt = 0; attempt < 100; attempt += 1) {
114
+ let descriptor;
92
115
  try {
93
- const descriptor = openSync(lock, 'wx', 0o600);
94
- try {
95
- return operation();
96
- }
97
- finally {
98
- closeSync(descriptor);
99
- unlinkSync(lock);
100
- }
116
+ descriptor = openSync(lock, 'wx', 0o600);
101
117
  }
102
118
  catch (error) {
103
119
  if (error.code !== 'EEXIST')
104
- return undefined;
120
+ throw error;
105
121
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
122
+ continue;
123
+ }
124
+ try {
125
+ return operation();
126
+ }
127
+ finally {
128
+ closeSync(descriptor);
129
+ unlinkSync(lock);
106
130
  }
107
131
  }
108
132
  return undefined;
109
133
  }
110
- function appendEvent(profile, event, options = {}) {
134
+ function withLocks(files, operation) {
135
+ const unique = [...new Set(files)].sort();
136
+ const acquire = (index) => index === unique.length ? operation() : withLock(unique[index], () => acquire(index + 1));
137
+ return acquire(0);
138
+ }
139
+ const authorityRank = { founder: 3, team: 2, system: 1 };
140
+ function compact(events) {
141
+ const content = events.filter((event) => event.kind === 'instruction' || event.kind === 'verified_candidate')
142
+ .sort((a, b) => authorityRank[b.authority] - authorityRank[a.authority] || b.weight - a.weight || b.timestamp.localeCompare(a.timestamp) || a.eventId.localeCompare(b.eventId));
143
+ const selected = new Set(content.slice(0, MAX_EVENTS).map((event) => event.eventId));
144
+ const controls = events.filter((event) => event.kind === 'migration' || ((event.kind === 'ratification' || event.kind === 'supersession') && selected.has(event.targetEventId ?? '')));
145
+ while (selected.size + controls.length > MAX_EVENTS)
146
+ selected.delete(content[[...selected].length - 1]?.eventId ?? '');
147
+ const retained = events.filter((event) => selected.has(event.eventId) || (event.kind === 'migration') || ((event.kind === 'ratification' || event.kind === 'supersession') && selected.has(event.targetEventId ?? '')))
148
+ .sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.eventId.localeCompare(b.eventId));
149
+ while (retained.length && Buffer.byteLength(serialize(retained)) > MAX_STORAGE_BYTES) {
150
+ const removable = retained.findIndex((event) => event.kind === 'instruction' || event.kind === 'verified_candidate');
151
+ retained.splice(removable < 0 ? 0 : removable, 1);
152
+ }
153
+ return retained;
154
+ }
155
+ function writeEventsAtomically(file, events) {
156
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
157
+ try {
158
+ writeFileSync(temporary, serialize(compact(events)), { encoding: 'utf8', mode: 0o600 });
159
+ renameSync(temporary, file);
160
+ }
161
+ catch (error) {
162
+ try {
163
+ unlinkSync(temporary);
164
+ }
165
+ catch { /* best-effort cleanup */ }
166
+ throw error;
167
+ }
168
+ }
169
+ function receipt(profile, event, status) {
170
+ const publicMetadata = { mutationId: event.mutationId, eventId: event.eventId, profileId: identity(profile), profileRevision: revision(profile), status, timestamp: event.timestamp };
171
+ return { version: '1', ...publicMetadata, digest: digest(publicMetadata) };
172
+ }
173
+ function eventBase(profile, kind, options) {
174
+ const mutationId = options.mutationId ?? randomUUID();
175
+ const base = {
176
+ version: '2', eventId: digest({ profile: identity(profile), mutationId }), mutationId, timestamp: new Date().toISOString(),
177
+ profileRevision: revision(profile), revisionDigest: revisionDigest(profile), authority: options.authority ?? 'team',
178
+ provenance: options.provenance ?? 'local', weight: options.weight ?? 1, compatibility: options.compatibility ?? 'same-or-newer', kind,
179
+ };
180
+ return { ...base, requestDigest: digest({ ...base, eventId: undefined, timestamp: undefined, requestDigest: undefined }) };
181
+ }
182
+ function mutate(profile, event, options, validate) {
111
183
  try {
112
184
  const directory = learningDirectory(options);
113
185
  mkdirSync(directory, { recursive: true, mode: 0o700 });
114
186
  const file = eventFile(profile, options);
115
- const line = `${JSON.stringify(event)}\n`;
116
- const events = readEvents(profile, options);
117
- const needsCompaction = events.length >= MAX_EVENTS || (statSync(file, { throwIfNoEntry: false })?.size ?? 0) + Buffer.byteLength(line) > MAX_STORAGE_BYTES;
118
- if (needsCompaction) {
119
- const compacted = withCompactionLock(file, () => {
120
- const retained = [...readEvents(profile, options), event].slice(-MAX_EVENTS);
121
- while (Buffer.byteLength(serialize(retained)) > MAX_STORAGE_BYTES)
122
- retained.shift();
123
- writeFileSync(file, serialize(retained), { encoding: 'utf8', mode: 0o600 });
124
- });
125
- if (compacted === undefined)
126
- return false;
127
- }
128
- else {
129
- appendFileSync(file, line, { encoding: 'utf8', mode: 0o600 });
130
- }
131
- return true;
187
+ const result = withLock(file, () => {
188
+ const events = readEventsFromFile(file);
189
+ const existing = events.find((item) => item.mutationId === event.mutationId);
190
+ if (existing)
191
+ return receipt(profile, existing, existing.requestDigest === event.requestDigest ? 'already_recorded' : 'conflict');
192
+ const invalid = validate?.(events);
193
+ if (invalid)
194
+ return receipt(profile, event, invalid);
195
+ writeEventsAtomically(file, [...events, event]);
196
+ return receipt(profile, event, 'recorded');
197
+ });
198
+ return result ?? receipt(profile, event, 'lock_timeout');
132
199
  }
133
- catch {
134
- return false;
200
+ catch (error) {
201
+ return receipt(profile, event, error.message.includes('corrupt') ? 'corrupt' : 'write_failed');
135
202
  }
136
203
  }
137
204
  function countFindings(findings) {
@@ -151,35 +218,100 @@ export function recordVerifiedCandidate(profile, verification, candidate, option
151
218
  return 'nothing_to_learn';
152
219
  const original = countFindings([...verification.original.voiceDna.findings, ...verification.original.aiEditor.findings]);
153
220
  const candidateFindings = countFindings([...verification.candidate.voiceDna.findings, ...verification.candidate.aiEditor.findings]);
154
- const resolved = [...original].flatMap(([key, entry]) => {
155
- const count = entry.count - (candidateFindings.get(key)?.count ?? 0);
156
- return count > 0 ? [{ engine: entry.finding.engine, id: entry.finding.id, severity: entry.finding.severity, count }] : [];
157
- });
221
+ const resolved = [...original].flatMap(([key, entry]) => { const count = entry.count - (candidateFindings.get(key)?.count ?? 0); return count > 0 ? [{ engine: entry.finding.engine, id: entry.finding.id, severity: entry.finding.severity, count }] : []; });
158
222
  if (!resolved.length)
159
223
  return 'nothing_to_learn';
160
- const bounded = resolved.slice(0, MAX_RESOLVED_FINDINGS);
161
- const outcome = createHash('sha256').update(`${profileFingerprint(profile)}\0${candidate}`).digest('hex');
224
+ const outcome = createHash('sha256').update(`${identity(profile)}\0${candidate}`).digest('hex');
162
225
  if (readEvents(profile, options).some((event) => event.kind === 'verified_candidate' && event.outcome === outcome))
163
226
  return 'nothing_to_learn';
164
- return appendEvent(profile, { version: '1', timestamp: new Date().toISOString(), kind: 'verified_candidate', resolved: bounded, outcome }, options) ? 'recorded' : 'write_failed';
227
+ const event = { ...eventBase(profile, 'verified_candidate', { ...options, mutationId: options.mutationId ?? outcome }), resolved: resolved.slice(0, MAX_RESOLVED_FINDINGS), outcome };
228
+ event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
229
+ const result = mutate(profile, event, options);
230
+ return result.status === 'recorded' ? 'recorded' : result.status === 'already_recorded' ? 'nothing_to_learn' : 'write_failed';
165
231
  }
166
- export function addLearningInstruction(profile, instruction, options = {}) {
167
- const trimmed = normalizeInstruction(instruction);
168
- if (!trimmed)
232
+ export function recordLearningInstruction(profile, instruction, options = {}) {
233
+ const normalized = normalizeInstruction(instruction);
234
+ if (!normalized)
169
235
  throw new Error('Learning instructions cannot be empty.');
170
- if (trimmed.length > MAX_INSTRUCTION_CHARACTERS)
236
+ if (normalized.length > MAX_INSTRUCTION_CHARACTERS)
171
237
  throw new Error(`Learning instructions must be ${MAX_INSTRUCTION_CHARACTERS} characters or fewer.`);
172
- return appendEvent(profile, { version: '1', timestamp: new Date().toISOString(), kind: 'instruction', instruction: trimmed }, options);
238
+ const event = { ...eventBase(profile, 'instruction', options), instruction: normalized };
239
+ event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
240
+ return mutate(profile, event, options);
241
+ }
242
+ export function addLearningInstruction(profile, instruction, options = {}) { return recordLearningInstruction(profile, instruction, options).status === 'recorded'; }
243
+ export function ratifyLearningEvent(profile, targetEventId, options = {}) {
244
+ const event = { ...eventBase(profile, 'ratification', options), targetEventId };
245
+ event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
246
+ return mutate(profile, event, options, (events) => { const target = events.find((item) => item.eventId === targetEventId); return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined; });
247
+ }
248
+ export function supersedeLearningEvent(profile, targetEventId, options = {}) {
249
+ const event = { ...eventBase(profile, 'supersession', options), targetEventId };
250
+ event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
251
+ return mutate(profile, event, options, (events) => { const target = events.find((item) => item.eventId === targetEventId); return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined; });
252
+ }
253
+ export function migrateLearningV2ToV3(source, target, options = {}) {
254
+ const sourceFingerprint = profileFingerprint(source);
255
+ const migrationId = options.mutationId ?? `migration:${digest({ source: sourceFingerprint, target: target.id, revision: target.revision })}`;
256
+ const migrationOptions = { ...options, mutationId: migrationId };
257
+ const marker = { ...eventBase(target, 'migration', migrationOptions), sourceProfile: sourceFingerprint };
258
+ try {
259
+ const directory = learningDirectory(options);
260
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
261
+ const file = eventFile(target, options);
262
+ const sourceFile = eventFile(source, options);
263
+ const result = withLocks([sourceFile, file], () => {
264
+ const targetEvents = readEventsFromFile(file);
265
+ const existing = targetEvents.find((event) => event.mutationId === marker.mutationId);
266
+ if (existing)
267
+ return receipt(target, existing, 'already_recorded');
268
+ const migrated = readEventsFromFile(sourceFile).filter((event) => event.kind === 'instruction' || event.kind === 'verified_candidate').map((event) => ({
269
+ ...event, eventId: digest({ target: target.id, source: event.eventId }), mutationId: `migration:${marker.mutationId}:${event.mutationId}`,
270
+ profileRevision: target.revision, revisionDigest: target.revisionDigest, provenance: `migration:${sourceFingerprint}`,
271
+ }));
272
+ const intended = [...targetEvents, ...migrated, marker];
273
+ const compacted = compact(intended);
274
+ if (migrated.some((event) => !compacted.some((item) => item.eventId === event.eventId)) || !compacted.some((item) => item.eventId === marker.eventId))
275
+ return receipt(target, marker, 'capacity_exceeded');
276
+ writeEventsAtomically(file, intended);
277
+ return receipt(target, marker, 'recorded');
278
+ });
279
+ return result ?? receipt(target, marker, 'lock_timeout');
280
+ }
281
+ catch (error) {
282
+ return receipt(target, marker, error.message.includes('corrupt') ? 'corrupt' : 'write_failed');
283
+ }
284
+ }
285
+ export function inspectLearning(profile, options = {}) {
286
+ const events = readEvents(profile, options);
287
+ const { superseded, ratified } = learningState(events, profile);
288
+ return events.slice(-MAX_EVENTS).map((event) => {
289
+ const control = event.kind === 'ratification' || event.kind === 'supersession' || event.kind === 'migration';
290
+ const status = control ? 'control' : superseded.has(event.eventId) ? 'superseded' : isCompatible(event, profile) || ratified.has(event.eventId) ? 'active' : 'incompatible';
291
+ return {
292
+ version: '1', eventId: event.eventId, mutationId: event.mutationId, eventType: event.kind, timestamp: event.timestamp,
293
+ profileRevision: event.profileRevision, authority: event.authority, weight: event.weight,
294
+ compatibility: event.compatibility, status, ...(event.targetEventId ? { targetEventId: event.targetEventId } : {}),
295
+ };
296
+ });
173
297
  }
174
298
  export function composeLearning(profile, options = {}) {
299
+ const events = readEvents(profile, options);
300
+ const { superseded, ratified } = learningState(events, profile);
175
301
  const preferences = new Map();
176
- for (const event of readEvents(profile, options)) {
177
- const texts = event.kind === 'instruction' && event.instruction
178
- ? [{ text: event.instruction, count: 1 }]
179
- : (event.resolved ?? []).map((finding) => ({ text: `Previously verified repair: ${finding.engine}/${finding.id}.`, count: finding.count }));
302
+ for (const event of events) {
303
+ if (event.kind !== 'instruction' && event.kind !== 'verified_candidate')
304
+ continue;
305
+ if (superseded.has(event.eventId))
306
+ continue;
307
+ if (!isCompatible(event, profile) && !ratified.has(event.eventId))
308
+ continue;
309
+ const texts = event.kind === 'instruction' && event.instruction ? [{ text: event.instruction, count: event.weight }]
310
+ : (event.resolved ?? []).map((finding) => ({ text: `Previously verified repair: ${finding.engine}/${finding.id}.`, count: finding.count * event.weight }));
180
311
  for (const { text, count } of texts) {
181
- const current = preferences.get(text) ?? { count: 0, lastSeen: event.timestamp };
312
+ const current = preferences.get(text) ?? { count: 0, lastSeen: event.timestamp, authority: authorityRank[event.authority] };
182
313
  current.count += count;
314
+ current.authority = Math.max(current.authority, authorityRank[event.authority]);
183
315
  if (event.timestamp > current.lastSeen)
184
316
  current.lastSeen = event.timestamp;
185
317
  preferences.set(text, current);
@@ -187,9 +319,7 @@ export function composeLearning(profile, options = {}) {
187
319
  }
188
320
  const result = [];
189
321
  let characters = 0;
190
- for (const [text, value] of [...preferences.entries()]
191
- .sort(([firstText, first], [secondText, second]) => second.count - first.count || second.lastSeen.localeCompare(first.lastSeen) || firstText.localeCompare(secondText))
192
- .slice(0, MAX_PREFERENCES)) {
322
+ for (const [text, value] of [...preferences.entries()].sort(([aText, a], [bText, b]) => b.authority - a.authority || b.count - a.count || b.lastSeen.localeCompare(a.lastSeen) || aText.localeCompare(bText)).slice(0, MAX_PREFERENCES)) {
193
323
  if (characters + text.length > MAX_COMPOSED_CHARACTERS)
194
324
  break;
195
325
  result.push({ text, count: value.count });
@@ -200,8 +330,18 @@ export function composeLearning(profile, options = {}) {
200
330
  export function clearLearning(profile, options = {}) {
201
331
  const file = eventFile(profile, options);
202
332
  try {
203
- unlinkSync(file);
204
- return true;
333
+ const result = withLock(file, () => { try {
334
+ unlinkSync(file);
335
+ return true;
336
+ }
337
+ catch (error) {
338
+ if (error.code === 'ENOENT')
339
+ return false;
340
+ throw error;
341
+ } });
342
+ if (result === undefined)
343
+ throw new Error('Learning store lock timeout.');
344
+ return result;
205
345
  }
206
346
  catch (error) {
207
347
  if (error.code === 'ENOENT')