@maestroagora/agora 1.2.2 → 1.5.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.
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env node
2
+
3
+ // agora-voice: the measurement engine behind VOICE.
4
+ //
5
+ // `voice build` is a measurement task, not a description task. A model asked to
6
+ // describe an author's voice writes flattery, so every number this tool reports
7
+ // is computed from the corpus by a frozen pipeline and anything the corpus
8
+ // cannot support is written as insufficient data rather than guessed.
9
+ //
10
+ // Profiles are written to ~/.agora/voices/, never inside the skill directory.
11
+ // The documented update path replaces the installed skill directory, so a
12
+ // profile stored there is destroyed silently on the next update.
13
+
14
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { buildOverlapIndex, compare, phraseOverlap, renderReport } from "./voice/check.mjs";
20
+ import { measure } from "./voice/features.mjs";
21
+ import { runGates } from "./voice/gates.mjs";
22
+ import { collectCorpus } from "./voice/ingest.mjs";
23
+ import { PIPELINE } from "./voice/pipeline.mjs";
24
+ import { renderProfile } from "./voice/profile.mjs";
25
+
26
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
27
+
28
+ function usage() {
29
+ return `agora-voice: measure a corpus, write a voice profile, check a draft against one.
30
+
31
+ Usage:
32
+ agora-voice build --name <slug> --from <path|url> [--from <path|url>]...
33
+ agora-voice list
34
+ agora-voice check --voice <slug> <draft path>
35
+ agora-voice default --voice <slug>
36
+
37
+ Options:
38
+ --name <slug> Profile slug. Lowercase letters, digits, and hyphens.
39
+ --from <path|url> Corpus source. A file, a directory, or an http(s) URL.
40
+ Repeatable. Directories expand to readable text files.
41
+ --register <name> Label applied to every --from that follows it, until the
42
+ next --register. A register earns its own numbers only at
43
+ 2,500 clean words across 3 independent documents.
44
+ --voice <slug> Profile to check against or to make the default.
45
+ --store <path> Profile directory. Defaults to ~/.agora/voices.
46
+ --keep-headings Count headings as author prose. Off by default, because
47
+ headlines are often house-written.
48
+ --now <iso date> Freeze the created and updated dates. For reproducible runs.
49
+ --json Machine-readable output.
50
+ -h, --help Show this help.
51
+
52
+ Markdown, plain text, and HTML are read. Binary document formats are refused by
53
+ name rather than partially extracted, because a partial extraction would move
54
+ every admission threshold without saying so.
55
+
56
+ Examples:
57
+ agora-voice build --name house --register blog --from ./posts --register email --from ./letters
58
+ agora-voice check --voice house ./draft.md`;
59
+ }
60
+
61
+ function parseArgs(argv) {
62
+ const options = {
63
+ command: null,
64
+ name: null,
65
+ voice: null,
66
+ store: null,
67
+ sources: [],
68
+ positional: [],
69
+ keepHeadings: false,
70
+ now: null,
71
+ json: false,
72
+ help: false,
73
+ };
74
+ let register = null;
75
+
76
+ for (let index = 0; index < argv.length; index += 1) {
77
+ const arg = argv[index];
78
+ const takeValue = () => {
79
+ const next = argv[index + 1];
80
+ if (!next || next.startsWith("--")) throw new Error(`${arg} requires a value`);
81
+ index += 1;
82
+ return next;
83
+ };
84
+
85
+ if (arg === "-h" || arg === "--help") options.help = true;
86
+ else if (arg === "--json") options.json = true;
87
+ else if (arg === "--keep-headings") options.keepHeadings = true;
88
+ else if (arg === "--name") options.name = takeValue();
89
+ else if (arg === "--voice") options.voice = takeValue();
90
+ else if (arg === "--store") options.store = takeValue();
91
+ else if (arg === "--now") options.now = takeValue();
92
+ else if (arg === "--register") register = takeValue();
93
+ else if (arg === "--from") options.sources.push({ source: takeValue(), register });
94
+ else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`);
95
+ else if (options.command === null) options.command = arg;
96
+ else options.positional.push(arg);
97
+ }
98
+ options.register = register;
99
+ return options;
100
+ }
101
+
102
+ function storeDirectory(options) {
103
+ return options.store ? resolve(options.store) : join(homedir(), ".agora", "voices");
104
+ }
105
+
106
+ async function readIndex(directory) {
107
+ try {
108
+ return JSON.parse(await readFile(join(directory, "index.json"), "utf8"));
109
+ } catch {
110
+ return { default: null, profiles: {} };
111
+ }
112
+ }
113
+
114
+ async function writeIndex(directory, index) {
115
+ await writeFile(join(directory, "index.json"), `${JSON.stringify(index, null, 2)}\n`, "utf8");
116
+ }
117
+
118
+ function assertSlug(slug, option) {
119
+ if (!slug) throw new Error(`${option} is required`);
120
+ if (!SLUG_PATTERN.test(slug)) {
121
+ throw new Error(`${option} must be lowercase letters, digits, and hyphens: got '${slug}'`);
122
+ }
123
+ }
124
+
125
+ function reportGates(gates, corpus, refusedFirst = true) {
126
+ const lines = [];
127
+ if (refusedFirst && corpus.refused.length > 0) {
128
+ lines.push("Refused sources:", ...corpus.refused.map((message) => ` ${message}`), "");
129
+ }
130
+ lines.push(
131
+ `Documents: ${corpus.documents.length}`,
132
+ `Clean words: ${gates.clean_words} (raw ${corpus.documents.reduce((total, document) => total + document.raw_words, 0)})`,
133
+ `Tier: ${gates.tier.disposition}. ${gates.tier.reason}`,
134
+ `Independence: ${gates.independence.passed ? "passed" : "failed"}`,
135
+ );
136
+ for (const failure of gates.independence.failures) lines.push(` ${failure}`);
137
+ lines.push("Registers:");
138
+ for (const entry of gates.registers) {
139
+ lines.push(
140
+ ` ${entry.register}: ${entry.clean_words} clean words across ${entry.documents} documents, ${entry.numeric ? "own numbers issued" : `qualitative only (${entry.note})`}`,
141
+ );
142
+ }
143
+ if (gates.stability.length > 0) {
144
+ const unstable = gates.stability.filter((entry) => !entry.stable);
145
+ lines.push(
146
+ `Feature stability: ${gates.stability.length - unstable.length} of ${gates.stability.length} core features stable`,
147
+ );
148
+ for (const entry of unstable) lines.push(` dropped ${entry.feature}: ${entry.reason}`);
149
+ }
150
+ if (gates.heterogeneity.stopped) {
151
+ lines.push("Heterogeneity stop: this corpus is not profileable as one voice.");
152
+ for (const reason of gates.heterogeneity.reasons) lines.push(` ${reason}`);
153
+ if (gates.heterogeneity.remedy) lines.push(` ${gates.heterogeneity.remedy}`);
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+
158
+ async function build(options) {
159
+ assertSlug(options.name, "--name");
160
+ if (options.sources.length === 0) throw new Error("build needs at least one --from source");
161
+
162
+ // Collect one source group at a time so each document carries the register
163
+ // label that was in force when its --from was named.
164
+ const corpus = { documents: [], refused: [] };
165
+ const seen = new Set();
166
+ for (const entry of options.sources) {
167
+ const group = await collectCorpus([entry.source], { keepHeadings: options.keepHeadings });
168
+ corpus.refused.push(...group.refused);
169
+ for (const document of group.documents) {
170
+ if (seen.has(document.source)) continue;
171
+ seen.add(document.source);
172
+ corpus.documents.push({ ...document, register: entry.register });
173
+ }
174
+ }
175
+ corpus.documents.sort((left, right) => left.source.localeCompare(right.source));
176
+
177
+ if (corpus.documents.length === 0) {
178
+ const detail = corpus.refused.length > 0 ? `\n\nRefused sources:\n ${corpus.refused.join("\n ")}` : "";
179
+ throw new Error(`no readable documents were found in the supplied sources${detail}`);
180
+ }
181
+
182
+ const pooledText = corpus.documents.map((document) => document.text).join("\n\n");
183
+ const measured = measure(pooledText);
184
+ const gates = runGates(corpus.documents, measured);
185
+ const now = options.now || new Date().toISOString().slice(0, 10);
186
+
187
+ if (!gates.certified) {
188
+ const report = [
189
+ "No profile was certified.",
190
+ "",
191
+ reportGates(gates, corpus),
192
+ "",
193
+ "Nothing was written. Report what the corpus needs, supply it, and rerun.",
194
+ ].join("\n");
195
+ if (options.json) {
196
+ process.stdout.write(`${JSON.stringify({ certified: false, gates, refused: corpus.refused }, null, 2)}\n`);
197
+ } else {
198
+ process.stdout.write(`${report}\n`);
199
+ }
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+
204
+ const directory = storeDirectory(options);
205
+ await mkdir(directory, { recursive: true });
206
+ const profilePath = join(directory, `${options.name}.md`);
207
+ const measurementsPath = join(directory, `${options.name}.measurements.json`);
208
+
209
+ await writeFile(profilePath, renderProfile({ name: options.name, measured, gates, corpus, pipeline: PIPELINE, now }), "utf8");
210
+ await writeFile(
211
+ measurementsPath,
212
+ `${JSON.stringify(
213
+ {
214
+ name: options.name,
215
+ updated: now,
216
+ pipeline: PIPELINE,
217
+ confidence: gates.tier.confidence,
218
+ measured,
219
+ registers: gates.registers.map((entry) => ({
220
+ register: entry.register,
221
+ numeric: entry.numeric,
222
+ measured: entry.measured,
223
+ })),
224
+ // Hashed token runs only. The store never holds the corpus prose.
225
+ overlap_index: buildOverlapIndex(corpus.documents),
226
+ },
227
+ null,
228
+ 2,
229
+ )}\n`,
230
+ "utf8",
231
+ );
232
+
233
+ const index = await readIndex(directory);
234
+ index.profiles[options.name] = {
235
+ documents: corpus.documents.length,
236
+ clean_words: gates.clean_words,
237
+ registers: gates.registers.map((entry) => entry.register),
238
+ confidence: gates.tier.confidence,
239
+ pipeline: PIPELINE,
240
+ updated: now,
241
+ };
242
+ if (!index.default) index.default = options.name;
243
+ await writeIndex(directory, index);
244
+
245
+ if (options.json) {
246
+ process.stdout.write(`${JSON.stringify({ certified: true, profile: profilePath, gates }, null, 2)}\n`);
247
+ return;
248
+ }
249
+ process.stdout.write(
250
+ [
251
+ `Wrote ${profilePath}`,
252
+ `Wrote ${measurementsPath}`,
253
+ "",
254
+ reportGates(gates, corpus),
255
+ "",
256
+ "Read the profile's `## Not captured` section before writing with it. It lists what this corpus could not tell us.",
257
+ "",
258
+ ].join("\n"),
259
+ );
260
+ }
261
+
262
+ async function list(options) {
263
+ const directory = storeDirectory(options);
264
+ const index = await readIndex(directory);
265
+ const names = Object.keys(index.profiles).sort();
266
+ if (options.json) {
267
+ process.stdout.write(`${JSON.stringify({ store: directory, ...index }, null, 2)}\n`);
268
+ return;
269
+ }
270
+ if (names.length === 0) {
271
+ process.stdout.write(`No profiles in ${directory}.\n`);
272
+ return;
273
+ }
274
+ process.stdout.write(`Profiles in ${directory}:\n`);
275
+ for (const name of names) {
276
+ const entry = index.profiles[name];
277
+ process.stdout.write(
278
+ ` ${name}${index.default === name ? " (default)" : ""}: ${entry.clean_words} clean words, ${entry.documents} documents, registers [${entry.registers.join(", ")}], confidence ${entry.confidence}, updated ${entry.updated}\n`,
279
+ );
280
+ }
281
+ }
282
+
283
+ async function check(options) {
284
+ const directory = storeDirectory(options);
285
+ const index = await readIndex(directory);
286
+ const slug = options.voice || index.default;
287
+ assertSlug(slug, "--voice");
288
+ const draftPath = options.positional[0];
289
+ if (!draftPath) throw new Error("check needs a draft path");
290
+
291
+ let stored;
292
+ try {
293
+ stored = JSON.parse(await readFile(join(directory, `${slug}.measurements.json`), "utf8"));
294
+ } catch {
295
+ throw new Error(`no measurements found for '${slug}' in ${directory}. Run build first.`);
296
+ }
297
+ for (const [stage, version] of Object.entries(PIPELINE)) {
298
+ if (stored.pipeline[stage] !== version) {
299
+ throw new Error(
300
+ `pipeline mismatch on ${stage}: the profile was built with '${stored.pipeline[stage]}' and this tool runs '${version}'. A comparison across two pipelines is not a comparison. Rebuild the profile.`,
301
+ );
302
+ }
303
+ }
304
+
305
+ const draft = await readFile(resolve(draftPath), "utf8");
306
+ const requested = options.register ?? null;
307
+ const subprofile = requested
308
+ ? stored.registers.find((entry) => entry.register === requested && entry.numeric)
309
+ : null;
310
+ const registerMatched = requested === null || Boolean(subprofile);
311
+ const reference = subprofile?.measured ?? stored.measured;
312
+
313
+ const result = compare(draft, reference, { registerMatched });
314
+ const overlaps = phraseOverlap(draft, stored.overlap_index);
315
+
316
+ if (options.json) {
317
+ process.stdout.write(`${JSON.stringify({ voice: slug, draft: basename(draftPath), result, overlaps }, null, 2)}\n`);
318
+ return;
319
+ }
320
+ process.stdout.write(
321
+ `Checking ${basename(draftPath)} against '${slug}'${requested ? ` (register ${requested})` : ""}\n\n${renderReport(result, overlaps)}\n`,
322
+ );
323
+ }
324
+
325
+ async function setDefault(options) {
326
+ assertSlug(options.voice, "--voice");
327
+ const directory = storeDirectory(options);
328
+ const index = await readIndex(directory);
329
+ if (!index.profiles[options.voice]) throw new Error(`no profile named '${options.voice}' in ${directory}`);
330
+ index.default = options.voice;
331
+ await writeIndex(directory, index);
332
+ process.stdout.write(`Default voice is now '${options.voice}'.\n`);
333
+ }
334
+
335
+ async function main() {
336
+ let options;
337
+ try {
338
+ options = parseArgs(process.argv.slice(2));
339
+ } catch (error) {
340
+ process.stderr.write(`Error: ${error.message}\n\n${usage()}\n`);
341
+ process.exitCode = 1;
342
+ return;
343
+ }
344
+ if (options.help || options.command === null) {
345
+ process.stdout.write(`${usage()}\n`);
346
+ return;
347
+ }
348
+
349
+ const commands = { build, list, check, default: setDefault };
350
+ const handler = commands[options.command];
351
+ if (!handler) {
352
+ process.stderr.write(`Error: unknown command '${options.command}'\n\n${usage()}\n`);
353
+ process.exitCode = 1;
354
+ return;
355
+ }
356
+ try {
357
+ await handler(options);
358
+ } catch (error) {
359
+ process.stderr.write(`Error: ${error.message}\n`);
360
+ process.exitCode = 1;
361
+ }
362
+ }
363
+
364
+ // Run only when invoked as a command, so the tests can import the parser.
365
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
366
+ await main();
367
+ }
368
+
369
+ export { main, parseArgs, storeDirectory };