@maestroagora/agora 1.2.1 → 1.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/.agents/plugins/marketplace.json +21 -21
- package/.claude-plugin/marketplace.json +13 -13
- package/.claude-plugin/plugin.json +21 -21
- package/.codex-plugin/plugin.json +33 -33
- package/LICENSE +21 -21
- package/README.md +58 -5
- package/assets/agora-orbit.svg +158 -158
- package/package.json +58 -55
- package/scripts/install.mjs +400 -400
- package/scripts/voice/check.mjs +175 -0
- package/scripts/voice/features.mjs +359 -0
- package/scripts/voice/gates.mjs +244 -0
- package/scripts/voice/ingest.mjs +226 -0
- package/scripts/voice/lexicon.mjs +162 -0
- package/scripts/voice/pipeline.mjs +186 -0
- package/scripts/voice/profile.mjs +528 -0
- package/scripts/voice-measure.mjs +369 -0
- package/skills/agora/SKILL.md +161 -15
- package/skills/agora/agents/openai.yaml +4 -4
- package/skills/agora/references/agora-craft.md +391 -0
- package/skills/agora/references/agora-marketing.md +653 -23
- package/skills/agora/references/agora-voice.md +262 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Corpus admission.
|
|
2
|
+
//
|
|
3
|
+
// Word counts come from authorship-attribution research on literary and
|
|
4
|
+
// journalistic corpora, not from a controlled study of generative voice
|
|
5
|
+
// synthesis. Every count below is a floor rather than a certificate, and the
|
|
6
|
+
// four structural rules on top of it are governance defaults chosen so the
|
|
7
|
+
// build has something checkable to enforce.
|
|
8
|
+
|
|
9
|
+
import { coreFeatureVector, measure } from "./features.mjs";
|
|
10
|
+
import { round } from "./pipeline.mjs";
|
|
11
|
+
|
|
12
|
+
export const CERTIFICATION_FLOOR = 5000;
|
|
13
|
+
export const PRODUCTION_MINIMUM = 10000;
|
|
14
|
+
export const PREFERRED_TIER = 20000;
|
|
15
|
+
export const MIN_DOCUMENTS = 10;
|
|
16
|
+
export const MAX_SINGLE_DOCUMENT_SHARE = 0.25;
|
|
17
|
+
export const REGISTER_MIN_WORDS = 2500;
|
|
18
|
+
export const REGISTER_MIN_DOCUMENTS = 3;
|
|
19
|
+
export const MAX_REGISTER_VARIANCE_SHARE = 0.3;
|
|
20
|
+
export const MAX_LEAVE_ONE_OUT_SHIFT = 0.2;
|
|
21
|
+
export const HETEROGENEITY_FAILURE_SHARE = 1 / 3;
|
|
22
|
+
// The register-variance rule asks how much of a feature's spread register
|
|
23
|
+
// explains. When a feature barely moves across the whole corpus there is no
|
|
24
|
+
// meaningful spread for register to explain, and the ratio becomes unstable:
|
|
25
|
+
// an arbitrarily small between-register difference divides an arbitrarily small
|
|
26
|
+
// total and reports a high share. A feature whose relative spread is below this
|
|
27
|
+
// value is treated as stable without evaluating the share. Governance default.
|
|
28
|
+
export const NEGLIGIBLE_RELATIVE_SPREAD = 0.05;
|
|
29
|
+
|
|
30
|
+
/** The confidence tier the clean word count permits. */
|
|
31
|
+
export function admissionTier(cleanWords) {
|
|
32
|
+
if (cleanWords < CERTIFICATION_FLOOR) {
|
|
33
|
+
return {
|
|
34
|
+
certified: false,
|
|
35
|
+
confidence: null,
|
|
36
|
+
disposition: "refused",
|
|
37
|
+
reason: `${cleanWords} clean words is below the ${CERTIFICATION_FLOOR}-word certification floor. Below the stable region, stylometric estimates are unreliable rather than merely noisy, so no profile is certified.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (cleanWords < PRODUCTION_MINIMUM) {
|
|
41
|
+
return {
|
|
42
|
+
certified: true,
|
|
43
|
+
confidence: "low",
|
|
44
|
+
disposition: "built with restricted features",
|
|
45
|
+
reason: `${cleanWords} clean words sits between the ${CERTIFICATION_FLOOR}-word floor and the ${PRODUCTION_MINIMUM}-word production minimum. Only features that stay stable on short texts are reported.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (cleanWords < PREFERRED_TIER) {
|
|
49
|
+
return {
|
|
50
|
+
certified: true,
|
|
51
|
+
confidence: "medium",
|
|
52
|
+
disposition: "production minimum met",
|
|
53
|
+
reason: `${cleanWords} clean words meets the ${PRODUCTION_MINIMUM}-word production minimum for a persistent profile.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
certified: true,
|
|
58
|
+
confidence: "production",
|
|
59
|
+
disposition: "preferred tier",
|
|
60
|
+
reason: `${cleanWords} clean words reaches the ${PREFERRED_TIER}-word preferred tier, which is a governance default chosen as an engineering target and not an empirical threshold.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function documentIndependence(documents, cleanWords) {
|
|
65
|
+
const largest = documents.reduce(
|
|
66
|
+
(winner, document) => (document.clean_words > (winner?.clean_words ?? -1) ? document : winner),
|
|
67
|
+
null,
|
|
68
|
+
);
|
|
69
|
+
const share = largest && cleanWords ? largest.clean_words / cleanWords : 0;
|
|
70
|
+
const failures = [];
|
|
71
|
+
if (documents.length < MIN_DOCUMENTS) {
|
|
72
|
+
failures.push(
|
|
73
|
+
`only ${documents.length} independently composed documents; the governance default is at least ${MIN_DOCUMENTS}, because cross-document stability is the thing being measured`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (share > MAX_SINGLE_DOCUMENT_SHARE) {
|
|
77
|
+
failures.push(
|
|
78
|
+
`${largest.source} supplies ${round(share * 100, 1)} percent of the clean tokens; the governance default caps any single document at ${MAX_SINGLE_DOCUMENT_SHARE * 100} percent`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
documents: documents.length,
|
|
83
|
+
largest_document_share_percent: round(share * 100, 1),
|
|
84
|
+
passed: failures.length === 0,
|
|
85
|
+
failures,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function registerSubprofiles(documents) {
|
|
90
|
+
const groups = new Map();
|
|
91
|
+
for (const document of documents) {
|
|
92
|
+
const register = document.register || "unlabelled";
|
|
93
|
+
if (!groups.has(register)) groups.set(register, []);
|
|
94
|
+
groups.get(register).push(document);
|
|
95
|
+
}
|
|
96
|
+
return [...groups.entries()]
|
|
97
|
+
.sort((left, right) => left[0].localeCompare(right[0]))
|
|
98
|
+
.map(([register, members]) => {
|
|
99
|
+
const cleanWords = members.reduce((total, member) => total + member.clean_words, 0);
|
|
100
|
+
const qualifies = cleanWords >= REGISTER_MIN_WORDS && members.length >= REGISTER_MIN_DOCUMENTS;
|
|
101
|
+
return {
|
|
102
|
+
register,
|
|
103
|
+
documents: members.length,
|
|
104
|
+
clean_words: cleanWords,
|
|
105
|
+
numeric: qualifies,
|
|
106
|
+
note: qualifies
|
|
107
|
+
? null
|
|
108
|
+
: `below the governance default of ${REGISTER_MIN_WORDS} clean words across ${REGISTER_MIN_DOCUMENTS} independent documents; observations here stay qualitative and every number is provisional`,
|
|
109
|
+
measured: qualifies ? measure(members.map((member) => member.text).join("\n\n")) : null,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function variance(values) {
|
|
115
|
+
if (values.length < 2) return 0;
|
|
116
|
+
const average = values.reduce((total, value) => total + value, 0) / values.length;
|
|
117
|
+
return values.reduce((total, value) => total + (value - average) ** 2, 0) / (values.length - 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A candidate global feature fails persistence when register accounts for more
|
|
122
|
+
* than 30 percent of its document-level variance, or when deleting one document
|
|
123
|
+
* moves its pooled estimate by more than 20 percent. Both are governance
|
|
124
|
+
* defaults. A failing feature moves into a register override or is dropped.
|
|
125
|
+
*/
|
|
126
|
+
function featureStability(documents, pooled) {
|
|
127
|
+
const perDocument = documents.map((document) => ({
|
|
128
|
+
register: document.register || "unlabelled",
|
|
129
|
+
vector: coreFeatureVector(measure(document.text)),
|
|
130
|
+
words: document.clean_words,
|
|
131
|
+
}));
|
|
132
|
+
const pooledVector = coreFeatureVector(pooled);
|
|
133
|
+
const results = [];
|
|
134
|
+
|
|
135
|
+
for (const [name, pooledValue] of Object.entries(pooledVector)) {
|
|
136
|
+
if (pooledValue === null) continue;
|
|
137
|
+
const values = perDocument.map((entry) => entry.vector[name]).filter((value) => value !== null);
|
|
138
|
+
if (values.length < 2) {
|
|
139
|
+
results.push({ feature: name, stable: false, reason: "too few documents supplied this feature" });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const total = variance(values);
|
|
144
|
+
const registers = new Map();
|
|
145
|
+
for (const entry of perDocument) {
|
|
146
|
+
if (entry.vector[name] === null) continue;
|
|
147
|
+
if (!registers.has(entry.register)) registers.set(entry.register, []);
|
|
148
|
+
registers.get(entry.register).push(entry.vector[name]);
|
|
149
|
+
}
|
|
150
|
+
let within = 0;
|
|
151
|
+
let weight = 0;
|
|
152
|
+
for (const group of registers.values()) {
|
|
153
|
+
if (group.length < 2) continue;
|
|
154
|
+
within += variance(group) * (group.length - 1);
|
|
155
|
+
weight += group.length - 1;
|
|
156
|
+
}
|
|
157
|
+
const withinAverage = weight === 0 ? total : within / weight;
|
|
158
|
+
const registerShare = total === 0 ? 0 : Math.max(0, (total - withinAverage) / total);
|
|
159
|
+
const relativeSpread = pooledValue === 0 ? 0 : Math.sqrt(total) / Math.abs(pooledValue);
|
|
160
|
+
const negligibleSpread = relativeSpread < NEGLIGIBLE_RELATIVE_SPREAD;
|
|
161
|
+
|
|
162
|
+
let worstShift = 0;
|
|
163
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
164
|
+
const withoutOne = values.filter((_, position) => position !== index);
|
|
165
|
+
const average = withoutOne.reduce((sum, value) => sum + value, 0) / withoutOne.length;
|
|
166
|
+
const shift = pooledValue === 0 ? 0 : Math.abs(average - pooledValue) / Math.abs(pooledValue);
|
|
167
|
+
worstShift = Math.max(worstShift, shift);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const reasons = [];
|
|
171
|
+
if (registers.size > 1 && !negligibleSpread && registerShare > MAX_REGISTER_VARIANCE_SHARE) {
|
|
172
|
+
reasons.push(
|
|
173
|
+
`register accounts for ${round(registerShare * 100, 1)} percent of document-level variance, above the ${MAX_REGISTER_VARIANCE_SHARE * 100} percent governance default`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (worstShift > MAX_LEAVE_ONE_OUT_SHIFT) {
|
|
177
|
+
reasons.push(
|
|
178
|
+
`deleting one document moves the pooled estimate by ${round(worstShift * 100, 1)} percent, above the ${MAX_LEAVE_ONE_OUT_SHIFT * 100} percent governance default`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
results.push({
|
|
182
|
+
feature: name,
|
|
183
|
+
stable: reasons.length === 0,
|
|
184
|
+
register_variance_share: negligibleSpread ? null : round(registerShare, 3),
|
|
185
|
+
relative_spread: round(relativeSpread, 4),
|
|
186
|
+
worst_leave_one_out_shift: round(worstShift, 3),
|
|
187
|
+
negligible_spread: negligibleSpread,
|
|
188
|
+
reason: reasons.join("; ") || null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return results;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Mark the corpus not profileable as one voice when more than a third of the
|
|
196
|
+
* proposed core features fail the stability rule, or when the clean corpus
|
|
197
|
+
* falls below the certification floor after exclusions. Offer to build two
|
|
198
|
+
* profiles rather than averaging two registers into a voice that belongs to
|
|
199
|
+
* nobody. Governance default.
|
|
200
|
+
*/
|
|
201
|
+
function heterogeneityStop(stability, tier, registers) {
|
|
202
|
+
const evaluated = stability.length;
|
|
203
|
+
const failed = stability.filter((entry) => !entry.stable).length;
|
|
204
|
+
const share = evaluated === 0 ? 1 : failed / evaluated;
|
|
205
|
+
const reasons = [];
|
|
206
|
+
if (evaluated > 0 && share > HETEROGENEITY_FAILURE_SHARE) {
|
|
207
|
+
reasons.push(
|
|
208
|
+
`${failed} of ${evaluated} core features fail the stability rule (${round(share * 100, 1)} percent, above the one-third governance default)`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (!tier.certified) reasons.push("the clean corpus falls below the certification floor after exclusions");
|
|
212
|
+
const namedRegisters = registers.filter((entry) => entry.register !== "unlabelled");
|
|
213
|
+
return {
|
|
214
|
+
stopped: reasons.length > 0,
|
|
215
|
+
failed_features: failed,
|
|
216
|
+
evaluated_features: evaluated,
|
|
217
|
+
reasons,
|
|
218
|
+
remedy:
|
|
219
|
+
reasons.length > 0 && namedRegisters.length > 1
|
|
220
|
+
? `Build one profile per register (${namedRegisters.map((entry) => entry.register).join(", ")}) rather than averaging them into a voice that belongs to nobody.`
|
|
221
|
+
: reasons.length > 0
|
|
222
|
+
? "Supply more independent documents, or label registers with --register so two profiles can be built instead of one average."
|
|
223
|
+
: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Run every admission gate over a cleaned corpus. */
|
|
228
|
+
export function runGates(documents, pooled) {
|
|
229
|
+
const cleanWords = documents.reduce((total, document) => total + document.clean_words, 0);
|
|
230
|
+
const tier = admissionTier(cleanWords);
|
|
231
|
+
const independence = documentIndependence(documents, cleanWords);
|
|
232
|
+
const registers = registerSubprofiles(documents);
|
|
233
|
+
const stability = tier.certified ? featureStability(documents, pooled) : [];
|
|
234
|
+
const heterogeneity = heterogeneityStop(stability, tier, registers);
|
|
235
|
+
return {
|
|
236
|
+
clean_words: cleanWords,
|
|
237
|
+
tier,
|
|
238
|
+
independence,
|
|
239
|
+
registers,
|
|
240
|
+
stability,
|
|
241
|
+
heterogeneity,
|
|
242
|
+
certified: tier.certified && !heterogeneity.stopped,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Corpus ingestion and cleaning.
|
|
2
|
+
//
|
|
3
|
+
// Every threshold in the admission rules counts clean author-controlled words:
|
|
4
|
+
// the author's own prose after quotations, forwarded text, copied source
|
|
5
|
+
// material, boilerplate, templates, legal disclaimers, automatic signatures,
|
|
6
|
+
// and house-written headlines are removed. Cleaning therefore runs before any
|
|
7
|
+
// counting, and each removal is recorded so the profile can report what left.
|
|
8
|
+
|
|
9
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { countTypography, normalize, tokenize } from "./pipeline.mjs";
|
|
13
|
+
|
|
14
|
+
export const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".text", ".html", ".htm"]);
|
|
15
|
+
export const REFUSED_EXTENSIONS = new Set([".docx", ".doc", ".pdf", ".rtf", ".odt", ".pages", ".epub"]);
|
|
16
|
+
const MAX_FETCH_BYTES = 5_000_000;
|
|
17
|
+
const FETCH_TIMEOUT_MS = 20_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Binary document formats are refused rather than parsed. Extracting prose from
|
|
21
|
+
* them needs a dependency this package does not ship, and a partial extraction
|
|
22
|
+
* would silently change the word counts every admission threshold depends on.
|
|
23
|
+
*/
|
|
24
|
+
export function refusalMessage(source, extension) {
|
|
25
|
+
return `${source}: ${extension} is not supported. Export it to Markdown, plain text, or HTML and rerun. Voice thresholds count clean words, and a partial extraction from a binary format would move every one of them without saying so.`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stripHtml(html) {
|
|
29
|
+
const withoutHead = html
|
|
30
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
31
|
+
.replace(/<(script|style|noscript|template|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
32
|
+
.replace(/<(nav|header|footer|aside|form)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
|
|
33
|
+
const blockquotes = (withoutHead.match(/<blockquote\b/gi) || []).length;
|
|
34
|
+
const headings = (withoutHead.match(/<h[1-6]\b/gi) || []).length;
|
|
35
|
+
const body = withoutHead
|
|
36
|
+
.replace(/<blockquote\b[^>]*>[\s\S]*?<\/blockquote>/gi, " ")
|
|
37
|
+
.replace(/<h[1-6]\b[^>]*>[\s\S]*?<\/h[1-6]>/gi, " ")
|
|
38
|
+
.replace(/<\/(p|div|li|tr|section|article|br)>/gi, "\n\n")
|
|
39
|
+
.replace(/<br\s*\/?>/gi, "\n\n")
|
|
40
|
+
.replace(/<[^>]+>/g, " ")
|
|
41
|
+
.replace(/ /gi, " ")
|
|
42
|
+
.replace(/&/gi, "&")
|
|
43
|
+
.replace(/</gi, "<")
|
|
44
|
+
.replace(/>/gi, ">")
|
|
45
|
+
.replace(/"/gi, '"')
|
|
46
|
+
.replace(/'|'/gi, "'");
|
|
47
|
+
return { text: body, removed: { blockquotes, headings } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stripMarkdown(markdown, options) {
|
|
51
|
+
const removed = { frontmatter: 0, code_blocks: 0, blockquotes: 0, headings: 0, tables: 0, signatures: 0 };
|
|
52
|
+
let text = markdown;
|
|
53
|
+
|
|
54
|
+
text = text.replace(/^---\n[\s\S]*?\n---\n/, () => {
|
|
55
|
+
removed.frontmatter += 1;
|
|
56
|
+
return "";
|
|
57
|
+
});
|
|
58
|
+
text = text.replace(/^```[\s\S]*?^```$/gm, () => {
|
|
59
|
+
removed.code_blocks += 1;
|
|
60
|
+
return "\n\n";
|
|
61
|
+
});
|
|
62
|
+
text = text.replace(/^(?: {4}|\t).*$/gm, "");
|
|
63
|
+
text = text.replace(/`[^`\n]+`/g, " ");
|
|
64
|
+
|
|
65
|
+
const lines = text.split("\n");
|
|
66
|
+
const kept = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (/^\s*>/.test(line)) {
|
|
69
|
+
removed.blockquotes += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) {
|
|
73
|
+
removed.headings += 1;
|
|
74
|
+
if (!options.keepHeadings) continue;
|
|
75
|
+
}
|
|
76
|
+
if (/^\s*\|.*\|\s*$/.test(line) || /^\s*\|?[\s:-]*-{3,}[\s:|-]*$/.test(line)) {
|
|
77
|
+
removed.tables += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/^\s*(--\s*$|Sent from my |Get Outlook for )/.test(line)) {
|
|
81
|
+
removed.signatures += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
kept.push(line);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
text: kept
|
|
89
|
+
.join("\n")
|
|
90
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
|
91
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
92
|
+
.replace(/^\s*[-*+]\s+/gm, "")
|
|
93
|
+
.replace(/^\s*\d+\.\s+/gm, "")
|
|
94
|
+
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1")
|
|
95
|
+
.replace(/^\s*(?:[-*_]\s*){3,}$/gm, ""),
|
|
96
|
+
removed,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Clean one document and count what the cleaning removed. */
|
|
101
|
+
export function cleanDocument(raw, { source, format, keepHeadings = false }) {
|
|
102
|
+
const typography = countTypography(raw);
|
|
103
|
+
const rawWords = tokenize(raw).length;
|
|
104
|
+
const html = format === "html";
|
|
105
|
+
const stage = html ? stripHtml(raw) : stripMarkdown(raw, { keepHeadings });
|
|
106
|
+
const cleanText = normalize(stage.text).replace(/\n{3,}/g, "\n\n").trim();
|
|
107
|
+
const tokens = tokenize(cleanText);
|
|
108
|
+
return {
|
|
109
|
+
source,
|
|
110
|
+
format,
|
|
111
|
+
text: cleanText,
|
|
112
|
+
tokens,
|
|
113
|
+
clean_words: tokens.length,
|
|
114
|
+
raw_words: rawWords,
|
|
115
|
+
removed: stage.removed,
|
|
116
|
+
typography,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatFor(name) {
|
|
121
|
+
const extension = extname(name).toLowerCase();
|
|
122
|
+
if (extension === ".html" || extension === ".htm") return "html";
|
|
123
|
+
return "markdown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A short, stable label for a document. Relative to the working directory when
|
|
128
|
+
* the file sits underneath it, and the parent folder plus the file name when it
|
|
129
|
+
* does not, so a profile never carries a long absolute path from the machine
|
|
130
|
+
* that built it.
|
|
131
|
+
*/
|
|
132
|
+
function labelFor(path, cwd) {
|
|
133
|
+
const relativePath = relative(cwd, path).replaceAll("\\", "/");
|
|
134
|
+
if (relativePath !== "" && !relativePath.startsWith("..")) return relativePath;
|
|
135
|
+
return `${basename(dirname(path))}/${basename(path)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function walk(directory) {
|
|
139
|
+
const found = [];
|
|
140
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
|
|
141
|
+
left.name.localeCompare(right.name),
|
|
142
|
+
)) {
|
|
143
|
+
if (entry.name.startsWith(".")) continue;
|
|
144
|
+
const path = join(directory, entry.name);
|
|
145
|
+
if (entry.isDirectory()) found.push(...(await walk(path)));
|
|
146
|
+
else if (entry.isFile()) found.push(path);
|
|
147
|
+
}
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function fetchDocument(url) {
|
|
152
|
+
const parsed = new URL(url);
|
|
153
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
154
|
+
throw new Error(`${url}: only http and https URLs can be fetched`);
|
|
155
|
+
}
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch(url, { redirect: "follow", signal: controller.signal });
|
|
160
|
+
if (!response.ok) throw new Error(`${url}: fetch returned ${response.status}`);
|
|
161
|
+
const type = (response.headers.get("content-type") || "").toLowerCase();
|
|
162
|
+
if (type.includes("pdf") || type.includes("officedocument") || type.includes("msword")) {
|
|
163
|
+
throw new Error(refusalMessage(url, type.split(";")[0]));
|
|
164
|
+
}
|
|
165
|
+
const body = await response.text();
|
|
166
|
+
if (body.length > MAX_FETCH_BYTES) {
|
|
167
|
+
throw new Error(`${url}: response exceeds the ${MAX_FETCH_BYTES} byte ceiling`);
|
|
168
|
+
}
|
|
169
|
+
return { raw: body, format: type.includes("text/html") ? "html" : "markdown" };
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve every --from argument into cleaned documents. Directories expand to
|
|
177
|
+
* their readable text files; unsupported formats are refused by name so the
|
|
178
|
+
* user learns which document was skipped and why.
|
|
179
|
+
*/
|
|
180
|
+
export async function collectCorpus(sources, { keepHeadings = false, cwd = process.cwd() } = {}) {
|
|
181
|
+
const documents = [];
|
|
182
|
+
const refused = [];
|
|
183
|
+
const targets = [];
|
|
184
|
+
|
|
185
|
+
for (const source of sources) {
|
|
186
|
+
if (/^https?:\/\//i.test(source)) {
|
|
187
|
+
targets.push({ kind: "url", value: source, label: source });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const path = resolve(cwd, source);
|
|
191
|
+
const info = await stat(path).catch(() => null);
|
|
192
|
+
if (!info) throw new Error(`${source}: no such file or directory`);
|
|
193
|
+
if (info.isDirectory()) {
|
|
194
|
+
for (const file of await walk(path)) {
|
|
195
|
+
targets.push({ kind: "file", value: file, label: labelFor(file, cwd) });
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
targets.push({ kind: "file", value: path, label: labelFor(path, cwd) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const target of targets) {
|
|
203
|
+
if (target.kind === "file") {
|
|
204
|
+
const extension = extname(target.value).toLowerCase();
|
|
205
|
+
if (REFUSED_EXTENSIONS.has(extension)) {
|
|
206
|
+
refused.push(refusalMessage(target.label, extension));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!TEXT_EXTENSIONS.has(extension)) continue;
|
|
210
|
+
const raw = await readFile(target.value, "utf8");
|
|
211
|
+
documents.push(cleanDocument(raw, { source: target.label, format: formatFor(target.value), keepHeadings }));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const fetched = await fetchDocument(target.value);
|
|
216
|
+
documents.push(
|
|
217
|
+
cleanDocument(fetched.raw, { source: target.label, format: fetched.format, keepHeadings }),
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
refused.push(error.message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
documents.sort((left, right) => left.source.localeCompare(right.source));
|
|
225
|
+
return { documents, refused };
|
|
226
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Frozen word lists for the agora-voice measurement pipeline.
|
|
2
|
+
//
|
|
3
|
+
// Every list here is part of the pipeline contract. Changing a list changes the
|
|
4
|
+
// numbers a profile reports, so a change must also raise LEXICON_VERSION in
|
|
5
|
+
// pipeline.mjs. A profile built under one lexicon version is not comparable to
|
|
6
|
+
// a draft measured under another.
|
|
7
|
+
|
|
8
|
+
export const ABBREVIATIONS = new Set([
|
|
9
|
+
"a.m", "p.m", "approx", "apr", "aug", "ave", "b.c", "a.d", "c.f", "capt",
|
|
10
|
+
"co", "corp", "dec", "dept", "dr", "e.g", "est", "etc", "feb", "fig", "fri",
|
|
11
|
+
"gen", "gov", "i.e", "inc", "jan", "jr", "jul", "jun", "lt", "ltd", "mar",
|
|
12
|
+
"messrs", "mon", "mr", "mrs", "ms", "mt", "no", "nov", "oct", "p.s", "ph.d",
|
|
13
|
+
"pp", "prof", "rev", "sat", "sep", "sept", "sgt", "sr", "st", "sun", "thu",
|
|
14
|
+
"tue", "u.k", "u.s", "u.s.a", "v.s", "vol", "vs", "wed",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Function words, grouped by class. Rates are reported per thousand tokens.
|
|
18
|
+
export const FUNCTION_WORDS = {
|
|
19
|
+
articles: ["a", "an", "the"],
|
|
20
|
+
prepositions: [
|
|
21
|
+
"about", "above", "across", "after", "against", "along", "among", "around",
|
|
22
|
+
"at", "before", "behind", "below", "beneath", "beside", "between", "beyond",
|
|
23
|
+
"by", "despite", "down", "during", "except", "for", "from", "in", "inside",
|
|
24
|
+
"into", "near", "of", "off", "on", "onto", "outside", "over", "past",
|
|
25
|
+
"since", "through", "throughout", "to", "toward", "towards", "under",
|
|
26
|
+
"until", "up", "upon", "with", "within", "without",
|
|
27
|
+
],
|
|
28
|
+
auxiliaries: [
|
|
29
|
+
"am", "are", "be", "been", "being", "can", "could", "did", "do", "does",
|
|
30
|
+
"had", "has", "have", "is", "may", "might", "must", "shall", "should",
|
|
31
|
+
"was", "were", "will", "would",
|
|
32
|
+
],
|
|
33
|
+
conjunctions: [
|
|
34
|
+
"after", "although", "and", "as", "because", "before", "but", "either",
|
|
35
|
+
"for", "if", "neither", "nor", "once", "or", "since", "so", "than", "that",
|
|
36
|
+
"though", "unless", "until", "when", "whenever", "where", "whereas",
|
|
37
|
+
"wherever", "whether", "while", "yet",
|
|
38
|
+
],
|
|
39
|
+
pronouns: [
|
|
40
|
+
"he", "her", "hers", "herself", "him", "himself", "his", "i", "it", "its",
|
|
41
|
+
"itself", "me", "mine", "my", "myself", "our", "ours", "ourselves", "she",
|
|
42
|
+
"their", "theirs", "them", "themselves", "they", "us", "we", "what",
|
|
43
|
+
"which", "who", "whom", "whose", "you", "your", "yours", "yourself",
|
|
44
|
+
"yourselves",
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const PERSON_CLASSES = {
|
|
49
|
+
first_singular: ["i", "me", "my", "mine", "myself"],
|
|
50
|
+
first_plural: ["we", "us", "our", "ours", "ourselves"],
|
|
51
|
+
second: ["you", "your", "yours", "yourself", "yourselves"],
|
|
52
|
+
third_singular: ["he", "him", "his", "himself", "she", "her", "hers", "herself", "it", "its", "itself"],
|
|
53
|
+
third_plural: ["they", "them", "their", "theirs", "themselves"],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const HEDGES = [
|
|
57
|
+
"apparently", "arguably", "broadly", "generally", "largely", "likely",
|
|
58
|
+
"mainly", "maybe", "mostly", "often", "partly", "perhaps", "possibly",
|
|
59
|
+
"presumably", "probably", "quite", "rather", "relatively", "roughly",
|
|
60
|
+
"seemingly", "somewhat", "sometimes", "suggests", "tends", "typically",
|
|
61
|
+
"usually",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export const BOOSTERS = [
|
|
65
|
+
"absolutely", "always", "certainly", "clearly", "completely", "definitely",
|
|
66
|
+
"entirely", "essential", "every", "exactly", "extremely", "highly",
|
|
67
|
+
"incredibly", "indeed", "never", "obviously", "particularly", "precisely",
|
|
68
|
+
"really", "significantly", "strongly", "surely", "totally", "truly",
|
|
69
|
+
"undoubtedly", "utterly", "very",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const MODALS = [
|
|
73
|
+
"can", "could", "may", "might", "must", "ought", "shall", "should", "will",
|
|
74
|
+
"would",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Sentence-opening classes, checked in the order the features module applies.
|
|
78
|
+
export const OPENING_CLASSES = {
|
|
79
|
+
coordinator: ["and", "but", "or", "nor", "so", "yet", "for"],
|
|
80
|
+
subordinator: [
|
|
81
|
+
"after", "although", "as", "because", "before", "if", "once", "since",
|
|
82
|
+
"though", "unless", "until", "when", "whenever", "where", "whereas",
|
|
83
|
+
"wherever", "whether", "while",
|
|
84
|
+
],
|
|
85
|
+
question_word: ["how", "what", "when", "where", "which", "who", "whom", "whose", "why"],
|
|
86
|
+
discourse_marker: [
|
|
87
|
+
"additionally", "also", "anyway", "besides", "consequently", "conversely",
|
|
88
|
+
"furthermore", "however", "importantly", "instead", "meanwhile", "moreover",
|
|
89
|
+
"nevertheless", "nonetheless", "notably", "otherwise", "similarly",
|
|
90
|
+
"still", "therefore", "thus", "ultimately",
|
|
91
|
+
],
|
|
92
|
+
subject_pronoun: ["he", "i", "it", "she", "they", "we", "you"],
|
|
93
|
+
determiner: ["a", "an", "the", "this", "that", "these", "those", "each", "every", "no", "some", "any"],
|
|
94
|
+
expletive: ["there", "here"],
|
|
95
|
+
adverbial: [
|
|
96
|
+
"again", "almost", "already", "back", "even", "eventually", "everywhere",
|
|
97
|
+
"finally", "first", "immediately", "later", "lately", "now", "often",
|
|
98
|
+
"once", "only", "originally", "perhaps", "recently", "sometimes", "soon",
|
|
99
|
+
"suddenly", "then", "today", "tomorrow", "tonight", "usually", "yesterday",
|
|
100
|
+
],
|
|
101
|
+
preposition: FUNCTION_WORDS.prepositions,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Contractible pairs. The contraction rate counts contracted forms against the
|
|
105
|
+
// eligible contexts where the expanded form appears, so the two columns are
|
|
106
|
+
// measured on the same opportunity set rather than on raw frequency.
|
|
107
|
+
export const CONTRACTION_PAIRS = [
|
|
108
|
+
{ contracted: ["can't"], expanded: [["can", "not"], ["cannot"]] },
|
|
109
|
+
{ contracted: ["don't"], expanded: [["do", "not"]] },
|
|
110
|
+
{ contracted: ["doesn't"], expanded: [["does", "not"]] },
|
|
111
|
+
{ contracted: ["didn't"], expanded: [["did", "not"]] },
|
|
112
|
+
{ contracted: ["isn't"], expanded: [["is", "not"]] },
|
|
113
|
+
{ contracted: ["aren't"], expanded: [["are", "not"]] },
|
|
114
|
+
{ contracted: ["wasn't"], expanded: [["was", "not"]] },
|
|
115
|
+
{ contracted: ["weren't"], expanded: [["were", "not"]] },
|
|
116
|
+
{ contracted: ["won't"], expanded: [["will", "not"]] },
|
|
117
|
+
{ contracted: ["wouldn't"], expanded: [["would", "not"]] },
|
|
118
|
+
{ contracted: ["shouldn't"], expanded: [["should", "not"]] },
|
|
119
|
+
{ contracted: ["couldn't"], expanded: [["could", "not"]] },
|
|
120
|
+
{ contracted: ["haven't"], expanded: [["have", "not"]] },
|
|
121
|
+
{ contracted: ["hasn't"], expanded: [["has", "not"]] },
|
|
122
|
+
{ contracted: ["hadn't"], expanded: [["had", "not"]] },
|
|
123
|
+
{ contracted: ["it's"], expanded: [["it", "is"], ["it", "has"]] },
|
|
124
|
+
{ contracted: ["that's"], expanded: [["that", "is"]] },
|
|
125
|
+
{ contracted: ["there's"], expanded: [["there", "is"], ["there", "has"]] },
|
|
126
|
+
{ contracted: ["what's"], expanded: [["what", "is"]] },
|
|
127
|
+
{ contracted: ["here's"], expanded: [["here", "is"]] },
|
|
128
|
+
{ contracted: ["i'm"], expanded: [["i", "am"]] },
|
|
129
|
+
{ contracted: ["i've"], expanded: [["i", "have"]] },
|
|
130
|
+
{ contracted: ["i'll"], expanded: [["i", "will"]] },
|
|
131
|
+
{ contracted: ["i'd"], expanded: [["i", "would"], ["i", "had"]] },
|
|
132
|
+
{ contracted: ["you're"], expanded: [["you", "are"]] },
|
|
133
|
+
{ contracted: ["you've"], expanded: [["you", "have"]] },
|
|
134
|
+
{ contracted: ["you'll"], expanded: [["you", "will"]] },
|
|
135
|
+
{ contracted: ["we're"], expanded: [["we", "are"]] },
|
|
136
|
+
{ contracted: ["we've"], expanded: [["we", "have"]] },
|
|
137
|
+
{ contracted: ["we'll"], expanded: [["we", "will"]] },
|
|
138
|
+
{ contracted: ["they're"], expanded: [["they", "are"]] },
|
|
139
|
+
{ contracted: ["they've"], expanded: [["they", "have"]] },
|
|
140
|
+
{ contracted: ["they'll"], expanded: [["they", "will"]] },
|
|
141
|
+
{ contracted: ["let's"], expanded: [["let", "us"]] },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// The generic AI-vocabulary list the tell gate bans. A profile's owned list can
|
|
145
|
+
// suppress the ban for a measured word, and only for the words it measured.
|
|
146
|
+
// Sourced from the vocabulary section of the canonical reference.
|
|
147
|
+
export const GENERIC_AI_VOCABULARY = [
|
|
148
|
+
"bespoke", "bolster", "breathtaking", "comprehensive", "craft", "curated",
|
|
149
|
+
"cutting-edge", "delve", "elevate", "embark", "empower", "enhance",
|
|
150
|
+
"ecosystem", "essential", "facilitate", "forefront", "forge", "foster",
|
|
151
|
+
"game-changer", "groundbreaking", "harness", "holistic", "innovative",
|
|
152
|
+
"intricate", "invaluable", "journey", "landscape", "leverage", "meticulous",
|
|
153
|
+
"multifaceted", "navigate", "nuanced", "paramount", "pivotal", "powerhouse",
|
|
154
|
+
"profound", "realm", "revolutionary", "robust", "seamless", "showcase",
|
|
155
|
+
"spearhead", "state-of-the-art", "streamline", "tapestry", "testament",
|
|
156
|
+
"trailblazer", "transformative", "underscore", "unleash", "unlock",
|
|
157
|
+
"unparalleled", "unprecedented", "vibrant", "vital", "world-class",
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
export function classSet(words) {
|
|
161
|
+
return new Set(words.map((word) => word.toLowerCase()));
|
|
162
|
+
}
|