@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.
- package/Readme.md +23 -10
- package/dist/ai-editor-rules.js +5 -2
- package/dist/ai-editor.js +52 -9
- package/dist/ai-editor.test.js +62 -10
- package/dist/approval-capability.js +111 -0
- package/dist/approval-capability.test.js +52 -0
- package/dist/approval-context.js +54 -0
- package/dist/approval-context.test.js +38 -0
- package/dist/benchmark.js +232 -0
- package/dist/benchmark.test.js +328 -0
- package/dist/canonical-json.js +123 -0
- package/dist/canonical-json.test.js +24 -0
- package/dist/cli.js +272 -19
- package/dist/cli.test.js +205 -8
- package/dist/hygiene.js +6 -0
- package/dist/hygiene.test.js +7 -1
- package/dist/judgment-task.js +171 -0
- package/dist/judgment-task.test.js +162 -0
- package/dist/learning.js +240 -100
- package/dist/learning.test.js +203 -3
- package/dist/lifecycle-adapter.js +75 -0
- package/dist/lifecycle-adapter.test.js +56 -0
- package/dist/mcp-tools.js +101 -7
- package/dist/mcp-tools.test.js +156 -6
- package/dist/mcp.js +213 -6
- package/dist/mcp.test.js +210 -11
- package/dist/pipeline.js +78 -14
- package/dist/pipeline.test.js +36 -2
- package/dist/preservation.js +89 -0
- package/dist/preservation.test.js +22 -0
- package/dist/profile.js +87 -0
- package/dist/profile.test.js +114 -0
- package/dist/rebuild-task.js +226 -0
- package/dist/rebuild-task.test.js +179 -0
- package/dist/release-audit.test.js +111 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +62 -7
- package/dist/rule-reconciliation.test.js +50 -0
- package/dist/semantic-review.js +176 -7
- package/dist/semantic-review.test.js +98 -14
- package/dist/stage1-dry-run.test.js +39 -0
- package/dist/stage1-evaluation.js +579 -0
- package/dist/stage1-evaluation.test.js +184 -0
- package/dist/stage1-human-packet.test.js +102 -0
- package/dist/stage1-schema-contract.test.js +95 -0
- package/dist/stage2-human-packet.test.js +81 -0
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- package/package.json +2 -2
package/dist/learning.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
function
|
|
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
|
-
&&
|
|
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
|
|
44
|
-
if (
|
|
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
|
|
52
|
-
if (
|
|
53
|
-
|
|
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
|
|
80
|
+
function readEventsFromFile(file) {
|
|
81
|
+
let contents;
|
|
62
82
|
try {
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
87
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
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 (
|
|
236
|
+
if (normalized.length > MAX_INSTRUCTION_CHARACTERS)
|
|
171
237
|
throw new Error(`Learning instructions must be ${MAX_INSTRUCTION_CHARACTERS} characters or fewer.`);
|
|
172
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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')
|