agentera 3.0.0-dev.72 → 3.0.0-dev.74
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 +111 -0
- package/bundle/.agentera-build-source.json +8 -0
- package/bundle/CHANGELOG.md +17 -17
- package/bundle/extract-corpus-parity.json +1 -1
- package/bundle/references/adapters/package-publication.json +49 -53
- package/bundle/references/adapters/package-registry.yaml +5 -1
- package/bundle/references/adapters/package-surface-characterization.md +6 -4
- package/bundle/references/analysis/evidence-tier-authority.yaml +45 -4
- package/bundle/references/analysis/personal-glossary-evaluation-authority.yaml +175 -0
- package/bundle/references/analysis/personal-glossary-evaluation-corpus.yaml +291 -0
- package/bundle/references/analysis/personal-glossary-holdout.yaml +192 -0
- package/bundle/references/artifacts/glossary-entry-contract.yaml +1533 -35
- package/bundle/references/cli/update-channels.yaml +2 -2
- package/bundle/references/cli/vocabulary.md +1 -1
- package/bundle/references/meta/retained-reference-authority.yaml +15 -0
- package/bundle/skills/agentera/capabilities/profile/schemas/artifacts.yaml +11 -7
- package/bundle/skills/agentera/capabilities/profile/schemas/exit.yaml +6 -3
- package/bundle/skills/agentera/capabilities/profile/schemas/validation.yaml +13 -7
- package/bundle/skills/agentera/schemas/artifacts/glossary.yaml +5 -0
- package/bundle/skills/agentera/schemas/artifacts/plan.yaml +7 -0
- package/dist/.agentera-build-source.json +8 -0
- package/dist/analytics/extractCorpus/copilotSessions.js +13 -4
- package/dist/analytics/extractCorpus/core.js +223 -3
- package/dist/analytics/extractCorpus/corpus.js +18 -1
- package/dist/analytics/extractCorpus/cursorSessions.js +22 -4
- package/dist/analytics/extractCorpus/evidenceTiers.js +123 -17
- package/dist/analytics/extractCorpus/filesystemSources.js +4 -0
- package/dist/analytics/extractCorpus/index.js +2 -2
- package/dist/analytics/extractCorpus/jsonlSessions.js +52 -3
- package/dist/analytics/extractCorpus/sqliteSessions.js +12 -3
- package/dist/analytics/personalGlossaryAdmission.js +26 -46
- package/dist/analytics/personalGlossaryCandidateProjection.js +692 -0
- package/dist/analytics/personalGlossaryCandidateProjectionExcerpts.js +144 -0
- package/dist/analytics/personalGlossaryCandidateReadView.js +23 -0
- package/dist/analytics/personalGlossaryCurrentGeneration.js +25 -0
- package/dist/analytics/personalGlossaryDecision.js +203 -0
- package/dist/analytics/personalGlossaryExplicit.js +788 -0
- package/dist/analytics/personalGlossaryExplicitMining.js +285 -0
- package/dist/analytics/personalGlossaryExplicitScope.js +41 -0
- package/dist/analytics/personalGlossaryExplicitSegments.js +418 -0
- package/dist/analytics/personalGlossaryExplicitTypes.js +26 -0
- package/dist/analytics/personalGlossaryLexicalClassifier.js +175 -0
- package/dist/analytics/personalGlossaryProfile.js +13 -3
- package/dist/analytics/personalGlossaryRecurrence.js +618 -0
- package/dist/analytics/personalGlossaryReviewRecordStorage.js +428 -0
- package/dist/analytics/personalGlossaryReviewRecords.js +332 -0
- package/dist/capabilities/index.js +5 -1
- package/dist/capabilities/profile/instructions.js +50 -15
- package/dist/cli/commands/personalGlossaryCandidateReads.js +744 -0
- package/dist/cli/commands/personalGlossaryDecision.js +349 -0
- package/dist/cli/commands/personalGlossaryPublish.js +484 -0
- package/dist/cli/commands/personalGlossaryReviewRecordReads.js +209 -0
- package/dist/cli/commands/personalGlossaryReviewRecords.js +623 -0
- package/dist/cli/commands/prime/briefOrientation.js +118 -19
- package/dist/cli/commands/report.js +18 -2
- package/dist/cli/commands/schema.js +179 -0
- package/dist/cli/commands/verify.js +51 -9
- package/dist/cli/dispatch/lifecycle.js +51 -6
- package/dist/cli/help.js +48 -2
- package/dist/cli/migrationRequired.js +4 -1
- package/dist/core/atomicWriter.js +4 -1
- package/dist/core/developmentInvocation.js +4 -1
- package/dist/core/yaml.js +53 -1
- package/dist/eval/glossaryEvaluation.js +751 -0
- package/dist/eval/glossaryEvaluationProcess.js +29 -0
- package/dist/eval/glossaryEvaluationRunner.js +571 -0
- package/dist/eval/glossaryEvaluationSuccessReport.js +297 -0
- package/dist/registries/activationTuples.js +31 -3
- package/dist/registries/evidenceTierContract.js +4 -0
- package/dist/registries/explicitSegmentGrammarContract.js +619 -0
- package/dist/registries/glossaryCandidateContractPaths.js +6 -0
- package/dist/registries/glossaryCandidateContracts.js +820 -0
- package/dist/registries/glossaryCandidateDecisionAuthority.js +119 -0
- package/dist/registries/glossaryCandidateDecisionContract.js +62 -0
- package/dist/registries/glossaryCandidateProjectionAuthority.js +197 -0
- package/dist/registries/glossaryCandidateProjectionContract.js +104 -0
- package/dist/registries/glossaryEntryContract.js +105 -103
- package/dist/registries/glossaryEntryTemporal.js +12 -0
- package/dist/registries/glossaryMiningAuthority.js +764 -0
- package/dist/registries/glossaryProfileFullContract.js +24 -0
- package/dist/registries/glossaryReviewRecordsAuthority.js +426 -0
- package/dist/registries/glossaryReviewRecordsContract.js +129 -0
- package/dist/registries/glossaryTermIdentity.js +122 -0
- package/dist/registries/packagePublication.js +3 -3
- package/dist/registries/personalGlossaryContracts.js +1 -0
- package/dist/release/releaseMetadata.js +14 -7
- package/dist/upgrade/npxPlatformStatus.js +2 -1
- package/dist/upgrade/projectIntegration.js +7 -1
- package/dist/validate/activationArtifactEvidence.js +18 -4
- package/dist/validate/activationEvidenceManifest.js +101 -22
- package/package.json +2 -4
- package/dist/cli/commands/personalGlossary.js +0 -176
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { personalGlossaryCandidateProjectionContract } from "../registries/glossaryCandidateProjectionContract.js";
|
|
4
|
+
import { validateGlossaryEvidenceCapsule, } from "../registries/glossaryCandidateContracts.js";
|
|
5
|
+
import { canonicalGlossaryJson, compareGlossaryUnicodeStrings, glossaryCanonicalSha256, } from "../registries/glossaryTermIdentity.js";
|
|
6
|
+
import { defaultProfileDir } from "./extractCorpus/core.js";
|
|
7
|
+
import { EXCERPT_OMISSION_REASONS, containsPersonalGlossarySensitiveContent, personalGlossaryCandidateProjectionExcerptExpiry, selectPersonalGlossarySafeExcerpt, validPersonalGlossarySafeExcerpt, } from "./personalGlossaryCandidateProjectionExcerpts.js";
|
|
8
|
+
const PROJECTION_SCHEMA_VERSION = "agentera.personalGlossaryCandidateProjection.v1";
|
|
9
|
+
const PROJECTION_REPORT_SCHEMA_VERSION = "agentera.personalGlossaryCandidateProjectionReport.v1";
|
|
10
|
+
const PROJECT_IDENTITY_SCHEMA_VERSION = "agentera.personalGlossaryProjectionProjectIdentity.v1";
|
|
11
|
+
const PROJECTION_OWNER = "deterministic_discovery_projection";
|
|
12
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
13
|
+
const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
|
|
14
|
+
function mapping(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
16
|
+
? value
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
function compareText(left, right) {
|
|
20
|
+
return compareGlossaryUnicodeStrings(left, right);
|
|
21
|
+
}
|
|
22
|
+
function exactKeys(value, expected) {
|
|
23
|
+
const actual = Object.keys(value).sort(compareText);
|
|
24
|
+
return JSON.stringify(actual) === JSON.stringify([...expected].sort(compareText));
|
|
25
|
+
}
|
|
26
|
+
function validTimestamp(value) {
|
|
27
|
+
return typeof value === "string" && TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value));
|
|
28
|
+
}
|
|
29
|
+
function requireBoundedText(value, maximum, label) {
|
|
30
|
+
if (typeof value !== "string" ||
|
|
31
|
+
value.length === 0 ||
|
|
32
|
+
Buffer.byteLength(value, "utf8") > maximum) {
|
|
33
|
+
throw new TypeError(`${label} is outside its bound`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function projectionContract() {
|
|
38
|
+
const contract = personalGlossaryCandidateProjectionContract();
|
|
39
|
+
const families = contract.sourceFamilies;
|
|
40
|
+
if (contract.schemaVersion !== PROJECTION_SCHEMA_VERSION ||
|
|
41
|
+
contract.owner !== PROJECTION_OWNER ||
|
|
42
|
+
contract.candidatesMax !== 50 ||
|
|
43
|
+
contract.projectIdsMaxPerCandidate !== 100 ||
|
|
44
|
+
contract.sourceExcerptMaxUtf8Bytes !== 4096 ||
|
|
45
|
+
contract.pendingExcerptDays !== 30 ||
|
|
46
|
+
contract.selectionAlgorithm !==
|
|
47
|
+
"least_retained_source_family_then_project_then_canonical_candidate" ||
|
|
48
|
+
contract.tieBreak !== "candidate_id_then_candidate_revision_then_capsule_sha256" ||
|
|
49
|
+
contract.projectIdentitySchemaVersion !== PROJECT_IDENTITY_SCHEMA_VERSION ||
|
|
50
|
+
contract.storageFile !== "candidate-projection.json" ||
|
|
51
|
+
contract.candidateSecretReason !== "secret_content" ||
|
|
52
|
+
contract.excerptSensitiveContentAction !== "omit_complete_excerpt_before_projection" ||
|
|
53
|
+
JSON.stringify(Object.keys(families)) !== JSON.stringify(["explicit", "recurring"]) ||
|
|
54
|
+
JSON.stringify(families.explicit) !== JSON.stringify(["personal_explicit_definition"]) ||
|
|
55
|
+
JSON.stringify(families.recurring) !==
|
|
56
|
+
JSON.stringify(["personal_inferred_usage", "personal_inferred_conversation"])) {
|
|
57
|
+
throw new TypeError("personal glossary candidate projection contract is invalid");
|
|
58
|
+
}
|
|
59
|
+
return contract;
|
|
60
|
+
}
|
|
61
|
+
function sourceFamily(capsule, contract) {
|
|
62
|
+
const matches = Object.entries(contract.sourceFamilies)
|
|
63
|
+
.filter(([, kinds]) => kinds.includes(capsule.provenance_kind))
|
|
64
|
+
.map(([family]) => family);
|
|
65
|
+
if (matches.length !== 1)
|
|
66
|
+
throw new TypeError("candidate provenance has no projection source family");
|
|
67
|
+
return matches[0];
|
|
68
|
+
}
|
|
69
|
+
/** Hash the exact transient diversity label before it enters projection metadata or reports. */
|
|
70
|
+
export function personalGlossaryProjectionProjectIdentity(projectId) {
|
|
71
|
+
if (typeof projectId !== "string" || projectId.length === 0) {
|
|
72
|
+
throw new TypeError("candidate project identity must be non-empty");
|
|
73
|
+
}
|
|
74
|
+
return glossaryCanonicalSha256({
|
|
75
|
+
schema_version: PROJECT_IDENTITY_SCHEMA_VERSION,
|
|
76
|
+
project_id: projectId,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function candidateOrder(left, right) {
|
|
80
|
+
return (compareText(left.capsule.candidate_id, right.capsule.candidate_id) ||
|
|
81
|
+
compareText(left.capsule.candidate_revision, right.capsule.candidate_revision) ||
|
|
82
|
+
compareText(left.capsule.capsule_sha256, right.capsule.capsule_sha256));
|
|
83
|
+
}
|
|
84
|
+
function candidateKey(capsule) {
|
|
85
|
+
return `${capsule.candidate_id}\u0000${capsule.candidate_revision}`;
|
|
86
|
+
}
|
|
87
|
+
function projectCandidate(candidate, input, contract) {
|
|
88
|
+
const errors = validateGlossaryEvidenceCapsule(candidate.capsule);
|
|
89
|
+
if (errors.length > 0)
|
|
90
|
+
throw new TypeError("candidate capsule is invalid");
|
|
91
|
+
if (containsPersonalGlossarySensitiveContent(candidate.capsule.term) ||
|
|
92
|
+
containsPersonalGlossarySensitiveContent(candidate.capsule.meaning)) {
|
|
93
|
+
throw new TypeError(`candidate content is ineligible: ${contract.candidateSecretReason}`);
|
|
94
|
+
}
|
|
95
|
+
if (candidate.capsule.generation !== input.generation ||
|
|
96
|
+
candidate.capsule.policy_version !== input.policy_version) {
|
|
97
|
+
throw new TypeError("candidate capsule bindings do not match projection input");
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(candidate.project_ids) ||
|
|
100
|
+
candidate.project_ids.length === 0 ||
|
|
101
|
+
candidate.project_ids.length > contract.projectIdsMaxPerCandidate) {
|
|
102
|
+
throw new TypeError("candidate project identities are outside their bound");
|
|
103
|
+
}
|
|
104
|
+
const projectKeys = new Set();
|
|
105
|
+
for (const projectId of candidate.project_ids) {
|
|
106
|
+
const bounded = requireBoundedText(projectId, 256, "candidate project identity");
|
|
107
|
+
projectKeys.add(personalGlossaryProjectionProjectIdentity(bounded));
|
|
108
|
+
}
|
|
109
|
+
if (projectKeys.size > contract.projectIdsMaxPerCandidate) {
|
|
110
|
+
throw new TypeError("candidate project identities are outside their bound");
|
|
111
|
+
}
|
|
112
|
+
const excerpts = candidate.excerpts ?? [];
|
|
113
|
+
if (!Array.isArray(excerpts) || excerpts.some((excerpt) => typeof excerpt !== "string")) {
|
|
114
|
+
throw new TypeError("candidate excerpts must be strings");
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
capsule: candidate.capsule,
|
|
118
|
+
sourceFamily: sourceFamily(candidate.capsule, contract),
|
|
119
|
+
projectKeys,
|
|
120
|
+
excerpts: new Set(excerpts),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function mergeCandidates(input, contract) {
|
|
124
|
+
const merged = new Map();
|
|
125
|
+
let duplicates = 0;
|
|
126
|
+
for (const rawCandidate of input.candidates) {
|
|
127
|
+
const candidate = projectCandidate(rawCandidate, input, contract);
|
|
128
|
+
const key = candidateKey(candidate.capsule);
|
|
129
|
+
const existing = merged.get(key);
|
|
130
|
+
if (!existing) {
|
|
131
|
+
merged.set(key, candidate);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (existing.capsule.capsule_sha256 !== candidate.capsule.capsule_sha256 ||
|
|
135
|
+
canonicalGlossaryJson(existing.capsule) !== canonicalGlossaryJson(candidate.capsule)) {
|
|
136
|
+
throw new TypeError("candidate revision collision has non-identical capsule bytes");
|
|
137
|
+
}
|
|
138
|
+
duplicates += 1;
|
|
139
|
+
for (const projectKey of candidate.projectKeys)
|
|
140
|
+
existing.projectKeys.add(projectKey);
|
|
141
|
+
if (existing.projectKeys.size > contract.projectIdsMaxPerCandidate) {
|
|
142
|
+
throw new TypeError("candidate project identities are outside their bound");
|
|
143
|
+
}
|
|
144
|
+
for (const excerpt of candidate.excerpts)
|
|
145
|
+
existing.excerpts.add(excerpt);
|
|
146
|
+
}
|
|
147
|
+
return { candidates: [...merged.values()].sort(candidateOrder), duplicates };
|
|
148
|
+
}
|
|
149
|
+
function countByFamily(candidates) {
|
|
150
|
+
const counts = new Map([
|
|
151
|
+
["explicit", 0],
|
|
152
|
+
["recurring", 0],
|
|
153
|
+
]);
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
counts.set(candidate.sourceFamily, (counts.get(candidate.sourceFamily) ?? 0) + 1);
|
|
156
|
+
}
|
|
157
|
+
return counts;
|
|
158
|
+
}
|
|
159
|
+
function countByProject(candidates) {
|
|
160
|
+
const counts = new Map();
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
for (const projectKey of candidate.projectKeys) {
|
|
163
|
+
counts.set(projectKey, (counts.get(projectKey) ?? 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return counts;
|
|
167
|
+
}
|
|
168
|
+
function selectCandidates(candidates, contract) {
|
|
169
|
+
const remaining = [...candidates];
|
|
170
|
+
const familyRetained = new Map([
|
|
171
|
+
["explicit", 0],
|
|
172
|
+
["recurring", 0],
|
|
173
|
+
]);
|
|
174
|
+
const projectRetained = new Map([...countByProject(candidates).keys()].map((projectKey) => [projectKey, 0]));
|
|
175
|
+
const retained = [];
|
|
176
|
+
let ties = 0;
|
|
177
|
+
const rank = (candidate) => [
|
|
178
|
+
familyRetained.get(candidate.sourceFamily) ?? 0,
|
|
179
|
+
Math.min(...[...candidate.projectKeys].map((projectKey) => projectRetained.get(projectKey) ?? 0)),
|
|
180
|
+
];
|
|
181
|
+
while (remaining.length > 0 && retained.length < contract.candidatesMax) {
|
|
182
|
+
remaining.sort((left, right) => {
|
|
183
|
+
const leftRank = rank(left);
|
|
184
|
+
const rightRank = rank(right);
|
|
185
|
+
return (leftRank[0] - rightRank[0] || leftRank[1] - rightRank[1] || candidateOrder(left, right));
|
|
186
|
+
});
|
|
187
|
+
const next = remaining.shift();
|
|
188
|
+
const nextRank = rank(next);
|
|
189
|
+
if (remaining.some((candidate) => {
|
|
190
|
+
const candidateRank = rank(candidate);
|
|
191
|
+
return candidateRank[0] === nextRank[0] && candidateRank[1] === nextRank[1];
|
|
192
|
+
})) {
|
|
193
|
+
ties += 1;
|
|
194
|
+
}
|
|
195
|
+
retained.push(next);
|
|
196
|
+
familyRetained.set(next.sourceFamily, (familyRetained.get(next.sourceFamily) ?? 0) + 1);
|
|
197
|
+
for (const projectKey of next.projectKeys) {
|
|
198
|
+
projectRetained.set(projectKey, (projectRetained.get(projectKey) ?? 0) + 1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { retained: retained.sort(candidateOrder), ties };
|
|
202
|
+
}
|
|
203
|
+
function emptyOmissions() {
|
|
204
|
+
return Object.fromEntries(EXCERPT_OMISSION_REASONS.map((reason) => [reason, 0]));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build the bounded, user-local candidate projection. This is an internal
|
|
208
|
+
* producer seam only. It deliberately exposes no list, exact-read, review, or
|
|
209
|
+
* publication CLI behavior.
|
|
210
|
+
*/
|
|
211
|
+
export function projectPersonalGlossaryCandidates(input) {
|
|
212
|
+
const contract = projectionContract();
|
|
213
|
+
requireBoundedText(input.generation, 256, "projection generation");
|
|
214
|
+
requireBoundedText(input.policy_version, 256, "projection policy version");
|
|
215
|
+
if (!validTimestamp(input.retained_at)) {
|
|
216
|
+
throw new TypeError("projection retained_at must be an ISO timestamp");
|
|
217
|
+
}
|
|
218
|
+
if (!Array.isArray(input.candidates)) {
|
|
219
|
+
throw new TypeError("projection candidates must be a list");
|
|
220
|
+
}
|
|
221
|
+
const merged = mergeCandidates(input, contract);
|
|
222
|
+
const selected = selectCandidates(merged.candidates, contract);
|
|
223
|
+
const availableByFamily = countByFamily(merged.candidates);
|
|
224
|
+
const availableByProject = countByProject(merged.candidates);
|
|
225
|
+
const retainedByFamily = countByFamily(selected.retained);
|
|
226
|
+
const retainedByProject = countByProject(selected.retained);
|
|
227
|
+
const omissions = emptyOmissions();
|
|
228
|
+
let provided = 0;
|
|
229
|
+
let safeRetained = 0;
|
|
230
|
+
let redacted = 0;
|
|
231
|
+
let truncated = 0;
|
|
232
|
+
const candidates = selected.retained.map((candidate) => {
|
|
233
|
+
const excerpt = selectPersonalGlossarySafeExcerpt(candidate.excerpts, candidate.capsule.term, input.retained_at, contract);
|
|
234
|
+
provided += excerpt.provided;
|
|
235
|
+
if (excerpt.excerpt !== null) {
|
|
236
|
+
safeRetained += 1;
|
|
237
|
+
if (excerpt.excerpt.redacted)
|
|
238
|
+
redacted += 1;
|
|
239
|
+
}
|
|
240
|
+
else if (excerpt.omission !== null) {
|
|
241
|
+
omissions[excerpt.omission] += 1;
|
|
242
|
+
}
|
|
243
|
+
if (excerpt.truncated)
|
|
244
|
+
truncated += 1;
|
|
245
|
+
return {
|
|
246
|
+
capsule: candidate.capsule,
|
|
247
|
+
source_family: candidate.sourceFamily,
|
|
248
|
+
project_keys: [...candidate.projectKeys].sort(compareText),
|
|
249
|
+
safe_excerpt: excerpt.excerpt,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const capApplied = merged.candidates.length > contract.candidatesMax;
|
|
253
|
+
const uncoveredSourceFamilies = ["explicit", "recurring"].filter((family) => (availableByFamily.get(family) ?? 0) > 0 && (retainedByFamily.get(family) ?? 0) === 0);
|
|
254
|
+
const uncoveredProjects = [...availableByProject.keys()].filter((projectKey) => (retainedByProject.get(projectKey) ?? 0) === 0).length;
|
|
255
|
+
const coverageReasons = [
|
|
256
|
+
...(capApplied ? ["candidate_cap"] : []),
|
|
257
|
+
...(uncoveredSourceFamilies.length > 0 ? ["source_family_uncovered"] : []),
|
|
258
|
+
...(uncoveredProjects > 0 ? ["project_uncovered"] : []),
|
|
259
|
+
];
|
|
260
|
+
const report = {
|
|
261
|
+
schema_version: PROJECTION_REPORT_SCHEMA_VERSION,
|
|
262
|
+
input_count: input.candidates.length,
|
|
263
|
+
duplicate_count: merged.duplicates,
|
|
264
|
+
unique_count: merged.candidates.length,
|
|
265
|
+
retained_count: candidates.length,
|
|
266
|
+
dropped_count: merged.candidates.length - candidates.length,
|
|
267
|
+
cap: { maximum: contract.candidatesMax, applied: capApplied },
|
|
268
|
+
allocation: {
|
|
269
|
+
algorithm: contract.selectionAlgorithm,
|
|
270
|
+
tie_break: contract.tieBreak,
|
|
271
|
+
tie_breaks_resolved: selected.ties,
|
|
272
|
+
},
|
|
273
|
+
source_families: ["explicit", "recurring"].map((family) => ({
|
|
274
|
+
family,
|
|
275
|
+
available: availableByFamily.get(family) ?? 0,
|
|
276
|
+
retained: retainedByFamily.get(family) ?? 0,
|
|
277
|
+
dropped: (availableByFamily.get(family) ?? 0) - (retainedByFamily.get(family) ?? 0),
|
|
278
|
+
})),
|
|
279
|
+
projects: {
|
|
280
|
+
available: availableByProject.size,
|
|
281
|
+
retained: [...retainedByProject.values()].filter((count) => count > 0).length,
|
|
282
|
+
dropped: uncoveredProjects,
|
|
283
|
+
},
|
|
284
|
+
coverage: {
|
|
285
|
+
status: coverageReasons.length === 0 ? "complete" : "degraded",
|
|
286
|
+
reasons: coverageReasons,
|
|
287
|
+
uncovered_source_families: [...uncoveredSourceFamilies],
|
|
288
|
+
uncovered_projects: uncoveredProjects,
|
|
289
|
+
},
|
|
290
|
+
excerpts: {
|
|
291
|
+
provided,
|
|
292
|
+
retained: safeRetained,
|
|
293
|
+
redacted,
|
|
294
|
+
truncated,
|
|
295
|
+
expired: 0,
|
|
296
|
+
omissions,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
const projection = {
|
|
300
|
+
schema_version: PROJECTION_SCHEMA_VERSION,
|
|
301
|
+
owner: PROJECTION_OWNER,
|
|
302
|
+
generation: input.generation,
|
|
303
|
+
policy_version: input.policy_version,
|
|
304
|
+
retained_at: input.retained_at,
|
|
305
|
+
candidates,
|
|
306
|
+
report,
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
...projection,
|
|
310
|
+
projection_sha256: glossaryCanonicalSha256(projection),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
export function personalGlossaryCandidateProjectionPath(options = {}) {
|
|
314
|
+
const contract = projectionContract();
|
|
315
|
+
return path.join(defaultProfileDir(options.env ?? process.env, options.platform ?? process.platform), "intermediate", "personal-glossary", contract.storageFile);
|
|
316
|
+
}
|
|
317
|
+
function privateWrite(pathname, text) {
|
|
318
|
+
const directory = path.dirname(pathname);
|
|
319
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
320
|
+
fs.chmodSync(directory, 0o700);
|
|
321
|
+
const temporary = `${pathname}.tmp.${process.pid}.${Date.now()}`;
|
|
322
|
+
let descriptor = null;
|
|
323
|
+
try {
|
|
324
|
+
descriptor = fs.openSync(temporary, "w", 0o600);
|
|
325
|
+
fs.writeFileSync(descriptor, text, "utf8");
|
|
326
|
+
fs.fsyncSync(descriptor);
|
|
327
|
+
fs.closeSync(descriptor);
|
|
328
|
+
descriptor = null;
|
|
329
|
+
fs.renameSync(temporary, pathname);
|
|
330
|
+
fs.chmodSync(pathname, 0o600);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (descriptor !== null)
|
|
334
|
+
fs.closeSync(descriptor);
|
|
335
|
+
try {
|
|
336
|
+
fs.rmSync(temporary, { force: true });
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// The temporary file may not have been created or may already be renamed.
|
|
340
|
+
}
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function ensurePrivateProjectionMode(pathname) {
|
|
345
|
+
const metadata = fs.statSync(pathname);
|
|
346
|
+
if (!metadata.isFile()) {
|
|
347
|
+
throw new TypeError("stored candidate projection is not a private file");
|
|
348
|
+
}
|
|
349
|
+
if ((metadata.mode & 0o777) === 0o600)
|
|
350
|
+
return;
|
|
351
|
+
fs.chmodSync(pathname, 0o600);
|
|
352
|
+
if ((fs.statSync(pathname).mode & 0o777) !== 0o600) {
|
|
353
|
+
throw new TypeError("stored candidate projection could not be made private");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function nonNegativeInteger(value) {
|
|
357
|
+
return Number.isInteger(value) && Number(value) >= 0;
|
|
358
|
+
}
|
|
359
|
+
function validProjectionReport(value) {
|
|
360
|
+
const report = mapping(value);
|
|
361
|
+
const cap = mapping(report?.cap);
|
|
362
|
+
const allocation = mapping(report?.allocation);
|
|
363
|
+
const projects = mapping(report?.projects);
|
|
364
|
+
const coverage = mapping(report?.coverage);
|
|
365
|
+
const excerpts = mapping(report?.excerpts);
|
|
366
|
+
const omissions = mapping(excerpts?.omissions);
|
|
367
|
+
if (report === null ||
|
|
368
|
+
!exactKeys(report, [
|
|
369
|
+
"schema_version",
|
|
370
|
+
"input_count",
|
|
371
|
+
"duplicate_count",
|
|
372
|
+
"unique_count",
|
|
373
|
+
"retained_count",
|
|
374
|
+
"dropped_count",
|
|
375
|
+
"cap",
|
|
376
|
+
"allocation",
|
|
377
|
+
"source_families",
|
|
378
|
+
"projects",
|
|
379
|
+
"coverage",
|
|
380
|
+
"excerpts",
|
|
381
|
+
]) ||
|
|
382
|
+
report.schema_version !== PROJECTION_REPORT_SCHEMA_VERSION ||
|
|
383
|
+
![
|
|
384
|
+
report.input_count,
|
|
385
|
+
report.duplicate_count,
|
|
386
|
+
report.unique_count,
|
|
387
|
+
report.retained_count,
|
|
388
|
+
report.dropped_count,
|
|
389
|
+
].every(nonNegativeInteger) ||
|
|
390
|
+
cap === null ||
|
|
391
|
+
!exactKeys(cap, ["maximum", "applied"]) ||
|
|
392
|
+
cap.maximum !== projectionContract().candidatesMax ||
|
|
393
|
+
typeof cap.applied !== "boolean" ||
|
|
394
|
+
allocation === null ||
|
|
395
|
+
!exactKeys(allocation, ["algorithm", "tie_break", "tie_breaks_resolved"]) ||
|
|
396
|
+
allocation.algorithm !== projectionContract().selectionAlgorithm ||
|
|
397
|
+
allocation.tie_break !== projectionContract().tieBreak ||
|
|
398
|
+
!nonNegativeInteger(allocation.tie_breaks_resolved) ||
|
|
399
|
+
!Array.isArray(report.source_families) ||
|
|
400
|
+
report.source_families.length !== 2 ||
|
|
401
|
+
projects === null ||
|
|
402
|
+
!exactKeys(projects, ["available", "retained", "dropped"]) ||
|
|
403
|
+
!Object.values(projects).every(nonNegativeInteger) ||
|
|
404
|
+
coverage === null ||
|
|
405
|
+
!exactKeys(coverage, [
|
|
406
|
+
"status",
|
|
407
|
+
"reasons",
|
|
408
|
+
"uncovered_source_families",
|
|
409
|
+
"uncovered_projects",
|
|
410
|
+
]) ||
|
|
411
|
+
!["complete", "degraded"].includes(String(coverage.status)) ||
|
|
412
|
+
!Array.isArray(coverage.reasons) ||
|
|
413
|
+
coverage.reasons.some((reason) => typeof reason !== "string" ||
|
|
414
|
+
!["candidate_cap", "source_family_uncovered", "project_uncovered"].includes(reason)) ||
|
|
415
|
+
!Array.isArray(coverage.uncovered_source_families) ||
|
|
416
|
+
coverage.uncovered_source_families.some((family) => family !== "explicit" && family !== "recurring") ||
|
|
417
|
+
!nonNegativeInteger(coverage.uncovered_projects) ||
|
|
418
|
+
excerpts === null ||
|
|
419
|
+
!exactKeys(excerpts, [
|
|
420
|
+
"provided",
|
|
421
|
+
"retained",
|
|
422
|
+
"redacted",
|
|
423
|
+
"truncated",
|
|
424
|
+
"expired",
|
|
425
|
+
"omissions",
|
|
426
|
+
]) ||
|
|
427
|
+
![
|
|
428
|
+
excerpts.provided,
|
|
429
|
+
excerpts.retained,
|
|
430
|
+
excerpts.redacted,
|
|
431
|
+
excerpts.truncated,
|
|
432
|
+
excerpts.expired,
|
|
433
|
+
].every(nonNegativeInteger) ||
|
|
434
|
+
omissions === null ||
|
|
435
|
+
!exactKeys(omissions, EXCERPT_OMISSION_REASONS) ||
|
|
436
|
+
!Object.values(omissions).every(nonNegativeInteger)) {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
return report.source_families.every((family) => {
|
|
440
|
+
const item = mapping(family);
|
|
441
|
+
return (item !== null &&
|
|
442
|
+
exactKeys(item, ["family", "available", "retained", "dropped"]) &&
|
|
443
|
+
(item.family === "explicit" || item.family === "recurring") &&
|
|
444
|
+
[item.available, item.retained, item.dropped].every(nonNegativeInteger));
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
function projectedCandidateOrder(left, right) {
|
|
448
|
+
return (compareText(left.capsule.candidate_id, right.capsule.candidate_id) ||
|
|
449
|
+
compareText(left.capsule.candidate_revision, right.capsule.candidate_revision) ||
|
|
450
|
+
compareText(left.capsule.capsule_sha256, right.capsule.capsule_sha256));
|
|
451
|
+
}
|
|
452
|
+
function sameTextArray(left, right) {
|
|
453
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
454
|
+
}
|
|
455
|
+
function validProjectedCandidate(value, projection, contract) {
|
|
456
|
+
const item = mapping(value);
|
|
457
|
+
if (item === null ||
|
|
458
|
+
!exactKeys(item, ["capsule", "source_family", "project_keys", "safe_excerpt"]) ||
|
|
459
|
+
!Array.isArray(item.project_keys) ||
|
|
460
|
+
item.project_keys.length === 0 ||
|
|
461
|
+
item.project_keys.length > contract.projectIdsMaxPerCandidate ||
|
|
462
|
+
item.project_keys.some((projectKey) => typeof projectKey !== "string" || !SHA256_RE.test(projectKey)) ||
|
|
463
|
+
new Set(item.project_keys).size !== item.project_keys.length ||
|
|
464
|
+
!sameTextArray(item.project_keys, [...item.project_keys].sort((left, right) => compareText(String(left), String(right)))) ||
|
|
465
|
+
(item.safe_excerpt !== null && !validPersonalGlossarySafeExcerpt(item.safe_excerpt))) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const capsule = item.capsule;
|
|
469
|
+
if (validateGlossaryEvidenceCapsule(capsule).length > 0)
|
|
470
|
+
return false;
|
|
471
|
+
if (containsPersonalGlossarySensitiveContent(capsule.term) ||
|
|
472
|
+
containsPersonalGlossarySensitiveContent(capsule.meaning)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
return ((item.source_family === "explicit" || item.source_family === "recurring") &&
|
|
477
|
+
capsule.generation === projection.generation &&
|
|
478
|
+
capsule.policy_version === projection.policy_version &&
|
|
479
|
+
sourceFamily(capsule, contract) === item.source_family &&
|
|
480
|
+
(item.safe_excerpt === null ||
|
|
481
|
+
item.safe_excerpt.expires_at ===
|
|
482
|
+
personalGlossaryCandidateProjectionExcerptExpiry(projection.retained_at, contract.pendingExcerptDays)));
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function validProjectionBindings(projection, contract) {
|
|
489
|
+
const { candidates, report } = projection;
|
|
490
|
+
const families = ["explicit", "recurring"];
|
|
491
|
+
if (report.retained_count !== candidates.length ||
|
|
492
|
+
report.unique_count !== report.retained_count + report.dropped_count ||
|
|
493
|
+
report.input_count !== report.unique_count + report.duplicate_count ||
|
|
494
|
+
report.cap.applied !== report.unique_count > contract.candidatesMax ||
|
|
495
|
+
report.dropped_count !== Math.max(0, report.unique_count - contract.candidatesMax) ||
|
|
496
|
+
report.retained_count !== Math.min(report.unique_count, contract.candidatesMax) ||
|
|
497
|
+
report.allocation.tie_breaks_resolved > report.retained_count) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
const candidateKeys = new Set();
|
|
501
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
502
|
+
const candidate = candidates[index];
|
|
503
|
+
const key = candidateKey(candidate.capsule);
|
|
504
|
+
if (candidateKeys.has(key))
|
|
505
|
+
return false;
|
|
506
|
+
candidateKeys.add(key);
|
|
507
|
+
if (index > 0 && projectedCandidateOrder(candidates[index - 1], candidate) >= 0)
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
const retainedByFamily = new Map(families.map((family) => [family, 0]));
|
|
511
|
+
const retainedProjectKeys = new Set();
|
|
512
|
+
for (const candidate of candidates) {
|
|
513
|
+
retainedByFamily.set(candidate.source_family, (retainedByFamily.get(candidate.source_family) ?? 0) + 1);
|
|
514
|
+
for (const projectKey of candidate.project_keys)
|
|
515
|
+
retainedProjectKeys.add(projectKey);
|
|
516
|
+
}
|
|
517
|
+
if (!sameTextArray(report.source_families.map((item) => item.family), families))
|
|
518
|
+
return false;
|
|
519
|
+
let availableCandidates = 0;
|
|
520
|
+
let droppedCandidates = 0;
|
|
521
|
+
for (const family of families) {
|
|
522
|
+
const item = report.source_families.find((entry) => entry.family === family);
|
|
523
|
+
const retained = retainedByFamily.get(family) ?? 0;
|
|
524
|
+
if (item.retained !== retained ||
|
|
525
|
+
item.available < item.retained ||
|
|
526
|
+
item.dropped !== item.available - item.retained) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
availableCandidates += item.available;
|
|
530
|
+
droppedCandidates += item.dropped;
|
|
531
|
+
}
|
|
532
|
+
if (availableCandidates !== report.unique_count || droppedCandidates !== report.dropped_count) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
if (report.projects.retained !== retainedProjectKeys.size ||
|
|
536
|
+
report.projects.available < report.projects.retained ||
|
|
537
|
+
report.projects.dropped !== report.projects.available - report.projects.retained) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
const uncoveredSourceFamilies = families.filter((family) => {
|
|
541
|
+
const item = report.source_families.find((entry) => entry.family === family);
|
|
542
|
+
return item.available > 0 && item.retained === 0;
|
|
543
|
+
});
|
|
544
|
+
const coverageReasons = [
|
|
545
|
+
...(report.cap.applied ? ["candidate_cap"] : []),
|
|
546
|
+
...(uncoveredSourceFamilies.length > 0 ? ["source_family_uncovered"] : []),
|
|
547
|
+
...(report.projects.dropped > 0 ? ["project_uncovered"] : []),
|
|
548
|
+
];
|
|
549
|
+
if (report.coverage.uncovered_projects !== report.projects.dropped ||
|
|
550
|
+
!sameTextArray(report.coverage.uncovered_source_families, uncoveredSourceFamilies) ||
|
|
551
|
+
!sameTextArray(report.coverage.reasons, coverageReasons) ||
|
|
552
|
+
report.coverage.status !== (coverageReasons.length === 0 ? "complete" : "degraded")) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const activeExcerpts = candidates.flatMap((candidate) => candidate.safe_excerpt === null ? [] : [candidate.safe_excerpt]);
|
|
556
|
+
const omissionCount = Object.values(report.excerpts.omissions).reduce((total, count) => total + count, 0);
|
|
557
|
+
if (report.excerpts.retained !== activeExcerpts.length ||
|
|
558
|
+
report.excerpts.provided !== candidates.length - report.excerpts.omissions.no_excerpt ||
|
|
559
|
+
report.excerpts.retained + report.excerpts.expired + omissionCount !== candidates.length ||
|
|
560
|
+
report.excerpts.redacted < activeExcerpts.filter((excerpt) => excerpt.redacted).length ||
|
|
561
|
+
report.excerpts.redacted > report.excerpts.retained + report.excerpts.expired ||
|
|
562
|
+
report.excerpts.truncated > report.excerpts.provided ||
|
|
563
|
+
report.excerpts.expired > report.excerpts.provided) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
function validProjection(value) {
|
|
569
|
+
const projection = mapping(value);
|
|
570
|
+
const contract = projectionContract();
|
|
571
|
+
if (projection === null ||
|
|
572
|
+
!exactKeys(projection, [
|
|
573
|
+
"schema_version",
|
|
574
|
+
"owner",
|
|
575
|
+
"generation",
|
|
576
|
+
"policy_version",
|
|
577
|
+
"retained_at",
|
|
578
|
+
"candidates",
|
|
579
|
+
"report",
|
|
580
|
+
"projection_sha256",
|
|
581
|
+
]) ||
|
|
582
|
+
projection.schema_version !== PROJECTION_SCHEMA_VERSION ||
|
|
583
|
+
projection.owner !== PROJECTION_OWNER ||
|
|
584
|
+
typeof projection.generation !== "string" ||
|
|
585
|
+
projection.generation.length === 0 ||
|
|
586
|
+
Buffer.byteLength(projection.generation, "utf8") > 256 ||
|
|
587
|
+
typeof projection.policy_version !== "string" ||
|
|
588
|
+
projection.policy_version.length === 0 ||
|
|
589
|
+
Buffer.byteLength(projection.policy_version, "utf8") > 256 ||
|
|
590
|
+
!validTimestamp(projection.retained_at) ||
|
|
591
|
+
!Array.isArray(projection.candidates) ||
|
|
592
|
+
projection.candidates.length > contract.candidatesMax ||
|
|
593
|
+
!validProjectionReport(projection.report) ||
|
|
594
|
+
typeof projection.projection_sha256 !== "string" ||
|
|
595
|
+
!SHA256_RE.test(projection.projection_sha256)) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
const body = { ...projection };
|
|
599
|
+
delete body.projection_sha256;
|
|
600
|
+
if (projection.projection_sha256 !== glossaryCanonicalSha256(body))
|
|
601
|
+
return false;
|
|
602
|
+
const typed = projection;
|
|
603
|
+
return (typed.candidates.every((candidate) => validProjectedCandidate(candidate, typed, contract)) &&
|
|
604
|
+
validProjectionBindings(typed, contract));
|
|
605
|
+
}
|
|
606
|
+
export function readPersonalGlossaryCandidateProjection(options = {}) {
|
|
607
|
+
const pathname = personalGlossaryCandidateProjectionPath(options);
|
|
608
|
+
let text;
|
|
609
|
+
try {
|
|
610
|
+
text = fs.readFileSync(pathname, "utf8");
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
return error.code === "ENOENT"
|
|
614
|
+
? { status: "missing", projection: null }
|
|
615
|
+
: { status: "corrupt", projection: null };
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const parsed = JSON.parse(text);
|
|
619
|
+
if (!validProjection(parsed) || text !== `${canonicalGlossaryJson(parsed)}\n`) {
|
|
620
|
+
return { status: "corrupt", projection: null };
|
|
621
|
+
}
|
|
622
|
+
return { status: "current", projection: parsed };
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return { status: "corrupt", projection: null };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
export function persistPersonalGlossaryCandidateProjection(projection, options = {}) {
|
|
629
|
+
if (!validProjection(projection))
|
|
630
|
+
throw new TypeError("candidate projection is invalid");
|
|
631
|
+
const pathname = personalGlossaryCandidateProjectionPath(options);
|
|
632
|
+
const text = `${canonicalGlossaryJson(projection)}\n`;
|
|
633
|
+
if (fs.existsSync(pathname)) {
|
|
634
|
+
const current = readPersonalGlossaryCandidateProjection(options);
|
|
635
|
+
if (current.status === "corrupt")
|
|
636
|
+
throw new TypeError("stored candidate projection is corrupt");
|
|
637
|
+
if (current.status === "current") {
|
|
638
|
+
const existing = fs.readFileSync(pathname, "utf8");
|
|
639
|
+
if (existing === text) {
|
|
640
|
+
ensurePrivateProjectionMode(pathname);
|
|
641
|
+
return { status: "unchanged_replay", path: pathname };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
privateWrite(pathname, text);
|
|
646
|
+
return { status: "changed", path: pathname };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Apply only expiry or a local-host-authorized purge to the private projection.
|
|
650
|
+
* No CLI command exposes this primitive. The later review lifecycle owns the
|
|
651
|
+
* authentication ceremony and any review-disposition storage.
|
|
652
|
+
*/
|
|
653
|
+
export function maintainPersonalGlossaryCandidateProjection(input) {
|
|
654
|
+
if (!validTimestamp(input.now))
|
|
655
|
+
throw new TypeError("maintenance now must be an ISO timestamp");
|
|
656
|
+
const options = { env: input.env, platform: input.platform };
|
|
657
|
+
const current = readPersonalGlossaryCandidateProjection(options);
|
|
658
|
+
if (current.status === "missing" || current.status === "corrupt") {
|
|
659
|
+
return { status: current.status, expired_excerpts: 0 };
|
|
660
|
+
}
|
|
661
|
+
const pathname = personalGlossaryCandidateProjectionPath(options);
|
|
662
|
+
if (input.current_user_purge_authorized === true) {
|
|
663
|
+
fs.rmSync(pathname, { force: true });
|
|
664
|
+
return { status: "purged", expired_excerpts: 0 };
|
|
665
|
+
}
|
|
666
|
+
let expired = 0;
|
|
667
|
+
const candidates = current.projection.candidates.map((candidate) => {
|
|
668
|
+
if (candidate.safe_excerpt !== null &&
|
|
669
|
+
Date.parse(candidate.safe_excerpt.expires_at) <= Date.parse(input.now)) {
|
|
670
|
+
expired += 1;
|
|
671
|
+
return { ...candidate, safe_excerpt: null };
|
|
672
|
+
}
|
|
673
|
+
return candidate;
|
|
674
|
+
});
|
|
675
|
+
if (expired === 0)
|
|
676
|
+
return { status: "unchanged", expired_excerpts: 0 };
|
|
677
|
+
const report = structuredClone(current.projection.report);
|
|
678
|
+
report.excerpts.retained -= expired;
|
|
679
|
+
report.excerpts.expired += expired;
|
|
680
|
+
const { projection_sha256: _previousDigest, ...previous } = current.projection;
|
|
681
|
+
const body = {
|
|
682
|
+
...previous,
|
|
683
|
+
candidates,
|
|
684
|
+
report,
|
|
685
|
+
};
|
|
686
|
+
const next = {
|
|
687
|
+
...body,
|
|
688
|
+
projection_sha256: glossaryCanonicalSha256(body),
|
|
689
|
+
};
|
|
690
|
+
privateWrite(pathname, `${canonicalGlossaryJson(next)}\n`);
|
|
691
|
+
return { status: "changed", expired_excerpts: expired };
|
|
692
|
+
}
|