faces-cli 1.7.16 → 1.8.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,81 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { BaseCommand } from '../../base.js';
5
+ import { FacesAPIError } from '../../client.js';
6
+ export default class StyleUpload extends BaseCommand {
7
+ static description = 'Load a corpus of a person\'s own writing (a mail export, a message archive) as source material for ' +
8
+ 'style capture. This only stores the material: nothing is compiled and nothing is billed. Capture ' +
9
+ 'the style afterwards with style:make.';
10
+ static examples = [
11
+ '<%= config.bin %> <%= command.id %> alice ./mail.jsonl',
12
+ '<%= config.bin %> <%= command.id %> alice ./thread.json --type thread_json',
13
+ '<%= config.bin %> <%= command.id %> alice ./mail.jsonl --messages-per-room 100 --strict',
14
+ ];
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ type: Flags.string({
18
+ description: 'email_jsonl: one message per line, split across several rooms. thread_json: a JSON array that ' +
19
+ 'becomes a single room.',
20
+ options: ['email_jsonl', 'thread_json'],
21
+ default: 'email_jsonl',
22
+ }),
23
+ 'messages-per-room': Flags.integer({
24
+ description: 'How many messages per room when the type is email_jsonl (1-1000, default: 50)',
25
+ default: 50,
26
+ }),
27
+ strict: Flags.boolean({
28
+ description: 'Reject the whole upload if any message is invalid. By default invalid messages are skipped and ' +
29
+ 'reported, because a corpus is statistical and a few bad lines are not worth losing the rest.',
30
+ default: false,
31
+ }),
32
+ };
33
+ static args = {
34
+ alias: Args.string({ description: 'Face alias', required: true }),
35
+ file: Args.string({ description: 'Corpus file', required: true }),
36
+ };
37
+ async run() {
38
+ const { args, flags } = await this.parse(StyleUpload);
39
+ const client = this.makeClient(flags);
40
+ const json = this.jsonEnabled();
41
+ if (!fs.existsSync(args.file))
42
+ this.error(`File not found: ${args.file}`);
43
+ const form = new FormData();
44
+ form.append('file', new Blob([fs.readFileSync(args.file)], { type: 'application/json' }), path.basename(args.file));
45
+ const params = new URLSearchParams({ type: flags.type });
46
+ if (flags.type === 'email_jsonl')
47
+ params.set('emails_per_thread', String(flags['messages-per-room']));
48
+ if (flags.strict)
49
+ params.set('strict', 'true');
50
+ let data;
51
+ try {
52
+ data = (await client.postForm(`/v1/faces/${encodeURIComponent(args.alias)}/upload?${params}`, form));
53
+ }
54
+ catch (err) {
55
+ if (err instanceof FacesAPIError) {
56
+ if (err.statusCode === 404)
57
+ this.error(`No face named '${args.alias}'. It does not exist, or is not yours.`);
58
+ if (err.statusCode === 422 && flags.strict) {
59
+ this.error(`Error (422): ${err.message}\nDrop --strict to skip invalid messages instead of refusing the upload.`);
60
+ }
61
+ this.error(`Error (${err.statusCode}): ${err.message}`);
62
+ }
63
+ throw err;
64
+ }
65
+ if (json)
66
+ return data;
67
+ const roomIds = data.room_ids ?? [];
68
+ const skipped = data.skipped;
69
+ this.log(`face: ${data.alias ?? args.alias}`);
70
+ this.log(`messages: ${data.message_count ?? '?'}`);
71
+ this.log(`rooms: ${data.room_count ?? roomIds.length}`);
72
+ // Never let a partial upload read as a complete one.
73
+ if (skipped?.length) {
74
+ this.log(`skipped: ${skipped.length} message(s) failed validation and were not stored.`);
75
+ this.log(' Re-run with --strict to refuse the whole upload instead.');
76
+ }
77
+ this.log('');
78
+ this.log(`Capture the style: faces style:make ${args.alias} --all`);
79
+ return data;
80
+ }
81
+ }
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../base.js';
2
+ export default class StyleVersions extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ static args: {
11
+ alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
12
+ };
13
+ run(): Promise<unknown>;
14
+ }
@@ -0,0 +1,77 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ import { FacesAPIError } from '../../client.js';
4
+ import { EXIT_NO_SUCH_FACE, VERSIONS_PATH, faceExists } from '../../style.js';
5
+ export default class StyleVersions extends BaseCommand {
6
+ static description = 'List the styles captured for a face, and which one it is writing in now. Each style:make keeps ' +
7
+ 'the previous one, so this is what style:revert has to go back to.';
8
+ static examples = ['<%= config.bin %> <%= command.id %> alice', '<%= config.bin %> <%= command.id %> alice --json'];
9
+ static flags = { ...BaseCommand.baseFlags };
10
+ static args = {
11
+ alias: Args.string({ description: 'Face alias', required: true }),
12
+ };
13
+ async run() {
14
+ const { args, flags } = await this.parse(StyleVersions);
15
+ const client = this.makeClient(flags);
16
+ let data;
17
+ try {
18
+ data = (await client.get(`${VERSIONS_PATH}/${encodeURIComponent(args.alias)}`));
19
+ }
20
+ catch (err) {
21
+ if (err instanceof FacesAPIError && err.statusCode === 404) {
22
+ // The 404 covers both "no style" and "no face". Only one is an error.
23
+ if (await faceExists(client, args.alias)) {
24
+ if (this.jsonEnabled())
25
+ return { self_face: args.alias, versions: [] };
26
+ this.log(`'${args.alias}' exists and has no captured style yet.`);
27
+ this.log(`Capture one with: faces style:make ${args.alias} --all`);
28
+ return { self_face: args.alias, versions: [] };
29
+ }
30
+ this.error(`No face named '${args.alias}'. It does not exist, or is not yours.`, { exit: EXIT_NO_SUCH_FACE });
31
+ }
32
+ if (err instanceof FacesAPIError)
33
+ this.error(`Error (${err.statusCode}): ${err.message}`);
34
+ throw err;
35
+ }
36
+ const versions = data.versions ?? [];
37
+ if (this.jsonEnabled())
38
+ return data;
39
+ if (versions.length === 0) {
40
+ this.log(`'${args.alias}' has no captured style. Capture one with: faces style:make ${args.alias} --all`);
41
+ return data;
42
+ }
43
+ const rows = versions.map((v) => ({
44
+ version: String(v.version ?? '-'),
45
+ active: v.active ? 'yes' : '-',
46
+ model: v.model?.trim() || '-',
47
+ created: String(v.created_at ?? '').slice(0, 19).replace('T', ' ') || '-',
48
+ }));
49
+ const cols = [
50
+ ['VERSION', (r) => r.version],
51
+ ['ACTIVE', (r) => r.active],
52
+ ['MODEL', (r) => r.model],
53
+ ['CAPTURED', (r) => r.created],
54
+ ];
55
+ const w = cols.map(([h, g]) => Math.max(h.length, ...rows.map((r) => g(r).length)));
56
+ const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(' ').trimEnd();
57
+ this.log(line(cols.map(([h]) => h)));
58
+ for (const r of rows)
59
+ this.log(line(cols.map(([, g]) => g(r))));
60
+ // More than one row can come back marked active (faces-backend-shared#589).
61
+ // Saying "you are writing in version N" would be a guess, so when the data
62
+ // is ambiguous the command says so instead of picking one.
63
+ const active = versions.filter((v) => v.active);
64
+ this.log('');
65
+ if (active.length === 1) {
66
+ this.log(`Writing in version ${active[0].version} now. ${versions.length} version(s) kept.`);
67
+ }
68
+ else if (active.length === 0) {
69
+ this.log(`${versions.length} version(s) kept. None is marked active.`);
70
+ }
71
+ else {
72
+ this.log(`${versions.length} version(s) kept, and ${active.length} are marked active, so which one is in ` +
73
+ 'use cannot be read from this list. Reported upstream.');
74
+ }
75
+ return data;
76
+ }
77
+ }
package/dist/routing.d.ts CHANGED
@@ -26,3 +26,12 @@ export declare function isCompositeFormula(modelArg: string): boolean;
26
26
  * default model from the local catalog, falling back to GET /v1/faces/{alias}.
27
27
  */
28
28
  export declare function resolveEndpoint(client: FacesClient, modelArg: string): Promise<RouteInfo>;
29
+ /**
30
+ * Whether a model runs on the user's own linked ChatGPT subscription, and so
31
+ * costs them nothing, or always bills.
32
+ *
33
+ * Returns undefined when the catalog cannot say — an unknown model, or a cache
34
+ * written before this field was read. Undefined must not be treated as "bills":
35
+ * refusing to run because we are out of date is worse than running.
36
+ */
37
+ export declare function codexEligible(client: FacesClient, llm: string): Promise<boolean | undefined>;
package/dist/routing.js CHANGED
@@ -131,3 +131,23 @@ export async function resolveEndpoint(client, modelArg) {
131
131
  return { endpoint: hit.endpoint, llm, freeWhenConnected: hit.freeWhenConnected };
132
132
  return { endpoint: fallbackEndpoint(llm), llm, freeWhenConnected: false };
133
133
  }
134
+ /**
135
+ * Whether a model runs on the user's own linked ChatGPT subscription, and so
136
+ * costs them nothing, or always bills.
137
+ *
138
+ * Returns undefined when the catalog cannot say — an unknown model, or a cache
139
+ * written before this field was read. Undefined must not be treated as "bills":
140
+ * refusing to run because we are out of date is worse than running.
141
+ */
142
+ export async function codexEligible(client, llm) {
143
+ const hits = (await getModels(client)).filter((m) => m.id === llm);
144
+ if (hits.length === 0)
145
+ return undefined;
146
+ // The catalog lists paid and OAuth twins under one id; eligible on either
147
+ // means the free route exists.
148
+ if (hits.some((m) => m.codex_eligible === true))
149
+ return true;
150
+ if (hits.every((m) => m.codex_eligible === false))
151
+ return false;
152
+ return undefined;
153
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared helpers for the `style` command family.
3
+ *
4
+ * "Style" is what the CLI calls the API's voiceprint: the writing manner
5
+ * captured from a person's own material and installed on their face. It is a
6
+ * separate act from `compile`, which captures what a face knows. One family
7
+ * answers "what does it know", the other "how does it write".
8
+ *
9
+ * The API names a source by a triple — id, medium, source_type. Users should
10
+ * only ever have to give an id, so everything here exists to turn one into the
11
+ * other from what the list endpoints already report.
12
+ */
13
+ import { FacesClient } from './client.js';
14
+ export declare const BUILDS_PATH = "/v1/voiceprint/builds";
15
+ export declare const VERSIONS_PATH = "/v1/voiceprint/versions";
16
+ export declare const STYLE_FACES_PATH = "/v1/voiceprint/faces";
17
+ /**
18
+ * Analyst model used when the caller does not choose one.
19
+ *
20
+ * Not the server default, deliberately. The server still defaults to an older
21
+ * model (faces-backend-shared#574) and the build is the most expensive call in
22
+ * the product, so the CLI names the current recommendation rather than
23
+ * inheriting a stale one.
24
+ */
25
+ export declare const DEFAULT_STYLE_MODEL = "gpt-5.6-terra";
26
+ /**
27
+ * Medium assumed for a room that does not declare one.
28
+ *
29
+ * Corpus rooms carry `corpus_kind`. Ordinary threads do not, because they are
30
+ * live conversations rather than an imported corpus — so a thread with no
31
+ * declared medium is a conversation, which is the one case where the medium
32
+ * follows from the source type rather than from a guess about content.
33
+ */
34
+ export declare const ROOM_DEFAULT_MEDIUM = "conversation";
35
+ /** Exit code for "no such face", so a caller can tell it from a face with no style. */
36
+ export declare const EXIT_NO_SUCH_FACE = 4;
37
+ /**
38
+ * Whether a face exists at all.
39
+ *
40
+ * The voiceprint routes cannot answer this. `GET /voiceprint/versions/{alias}`
41
+ * returns the same 404 for a face that has no style as for a face that does not
42
+ * exist, and `GET /voiceprint/builds?self_face=` returns an empty list for
43
+ * both. Those are opposite answers, so the face route is asked directly rather
44
+ * than reporting one as the other.
45
+ */
46
+ export declare function faceExists(client: FacesClient, alias: string): Promise<boolean>;
47
+ /** A source the CLI can name in a build request. */
48
+ export interface Selectable {
49
+ id: string;
50
+ sourceType: 'room' | 'document';
51
+ /** Resolved from the listing. Undefined means the caller has to declare it. */
52
+ medium?: string;
53
+ label: string;
54
+ synced: boolean;
55
+ /**
56
+ * True for imported corpus material. Distinguishes it from a live thread,
57
+ * which is the one source type a build may not compile for free.
58
+ */
59
+ isCorpus: boolean;
60
+ /** Present once this source has been printed. */
61
+ printedAt?: string;
62
+ printedMedium?: string;
63
+ }
64
+ /** What goes on the wire. `medium`, not `kind` — renamed 2026-08-27, no alias. */
65
+ export interface SourceRef {
66
+ id: string;
67
+ medium: string;
68
+ source_type: 'room' | 'document';
69
+ }
70
+ /**
71
+ * Every source on a face that a build could name.
72
+ *
73
+ * `include_uploads` matters: imported corpus rooms are excluded by default, and
74
+ * they are the main thing a style build is made from.
75
+ */
76
+ export declare function listSelectable(client: FacesClient, alias: string): Promise<Selectable[]>;
77
+ /**
78
+ * Parse one `--source` value: `<id>` or `<id>:<medium>`.
79
+ *
80
+ * Ids are UUIDs, so the first colon separates cleanly and a medium containing
81
+ * spaces ("academic paper") survives intact.
82
+ */
83
+ export declare function parseSourceArg(raw: string): {
84
+ id: string;
85
+ medium?: string;
86
+ };
87
+ /**
88
+ * Poll a voiceprint job until it stops.
89
+ *
90
+ * Returns the terminal payload including a failed one — whether a failure is an
91
+ * error is the caller's decision, not this function's.
92
+ */
93
+ export declare function pollStyleJob(client: FacesClient, basePath: string, jobId: string, opts?: {
94
+ intervalMs?: number;
95
+ timeoutMs?: number;
96
+ onPoll?: (d: Record<string, unknown>) => void;
97
+ }): Promise<Record<string, unknown>>;
98
+ /** Failure text, with the one hint that is actionable rather than descriptive. */
99
+ export declare function jobFailureMessage(error: unknown): string;
100
+ /**
101
+ * A job payload safe to hand onward, with its report reduced to public fields.
102
+ *
103
+ * Applied to --json too. A machine consumer reading our output is as much a way
104
+ * for an internal key to escape as a terminal is.
105
+ */
106
+ export declare function publicJob(data: Record<string, unknown>): Record<string, unknown>;
107
+ /** Human-readable build summary. Never dumps the report. */
108
+ export declare function formatBuildReport(data: Record<string, unknown>): string[];
package/dist/style.js ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Shared helpers for the `style` command family.
3
+ *
4
+ * "Style" is what the CLI calls the API's voiceprint: the writing manner
5
+ * captured from a person's own material and installed on their face. It is a
6
+ * separate act from `compile`, which captures what a face knows. One family
7
+ * answers "what does it know", the other "how does it write".
8
+ *
9
+ * The API names a source by a triple — id, medium, source_type. Users should
10
+ * only ever have to give an id, so everything here exists to turn one into the
11
+ * other from what the list endpoints already report.
12
+ */
13
+ import { FacesAPIError } from './client.js';
14
+ export const BUILDS_PATH = '/v1/voiceprint/builds';
15
+ export const VERSIONS_PATH = '/v1/voiceprint/versions';
16
+ export const STYLE_FACES_PATH = '/v1/voiceprint/faces';
17
+ /**
18
+ * Analyst model used when the caller does not choose one.
19
+ *
20
+ * Not the server default, deliberately. The server still defaults to an older
21
+ * model (faces-backend-shared#574) and the build is the most expensive call in
22
+ * the product, so the CLI names the current recommendation rather than
23
+ * inheriting a stale one.
24
+ */
25
+ export const DEFAULT_STYLE_MODEL = 'gpt-5.6-terra';
26
+ /**
27
+ * Medium assumed for a room that does not declare one.
28
+ *
29
+ * Corpus rooms carry `corpus_kind`. Ordinary threads do not, because they are
30
+ * live conversations rather than an imported corpus — so a thread with no
31
+ * declared medium is a conversation, which is the one case where the medium
32
+ * follows from the source type rather than from a guess about content.
33
+ */
34
+ export const ROOM_DEFAULT_MEDIUM = 'conversation';
35
+ /**
36
+ * What an import reports when it could not tell what the material is.
37
+ *
38
+ * Not a medium, and must not be sent as one — the build rejects it. It means
39
+ * the same as no answer, except that it came from imported material, where
40
+ * assuming a conversation would be a guess about content rather than a fact
41
+ * about the source type.
42
+ */
43
+ const UNIDENTIFIED_CORPUS = 'unknown';
44
+ /** Exit code for "no such face", so a caller can tell it from a face with no style. */
45
+ export const EXIT_NO_SUCH_FACE = 4;
46
+ /**
47
+ * Whether a face exists at all.
48
+ *
49
+ * The voiceprint routes cannot answer this. `GET /voiceprint/versions/{alias}`
50
+ * returns the same 404 for a face that has no style as for a face that does not
51
+ * exist, and `GET /voiceprint/builds?self_face=` returns an empty list for
52
+ * both. Those are opposite answers, so the face route is asked directly rather
53
+ * than reporting one as the other.
54
+ */
55
+ export async function faceExists(client, alias) {
56
+ try {
57
+ await client.get(`/v1/faces/${encodeURIComponent(alias)}`);
58
+ return true;
59
+ }
60
+ catch (err) {
61
+ if (err instanceof FacesAPIError && err.statusCode === 404)
62
+ return false;
63
+ // Anything else (network, auth, 5xx) is not evidence of absence.
64
+ throw err;
65
+ }
66
+ }
67
+ /**
68
+ * The medium a source already declares, or undefined when the caller has to.
69
+ *
70
+ * A live thread is the one case where the medium follows from the source type
71
+ * rather than from a guess: it is a conversation because that is what a thread
72
+ * is. Imported material is not — a corpus the importer could not classify could
73
+ * be anything, and calling it a conversation would teach the wrong voice for a
74
+ * medium with nothing afterwards to signal it happened.
75
+ */
76
+ function mediumOf(r, sourceType) {
77
+ if (sourceType === 'document')
78
+ return r.medium?.trim() || undefined;
79
+ const kind = r.corpus_kind?.trim();
80
+ if (!kind)
81
+ return ROOM_DEFAULT_MEDIUM;
82
+ return kind === UNIDENTIFIED_CORPUS ? undefined : kind;
83
+ }
84
+ function rowToSelectable(r, sourceType) {
85
+ const stamp = r.voiceprint ?? undefined;
86
+ return {
87
+ id: String(r.document_id ?? r.thread_id ?? ''),
88
+ sourceType,
89
+ // A document declares its own medium. A room declares one only when it came
90
+ // from a corpus import; anything else is a live conversation.
91
+ medium: mediumOf(r, sourceType),
92
+ label: r.label?.trim() || '(untitled)',
93
+ synced: Boolean(r.synced),
94
+ isCorpus: sourceType === 'document' || Boolean(r.corpus_kind?.trim()),
95
+ // The stamp was renamed alongside the request field, but stored stamps kept
96
+ // the old key (faces-backend-shared#589), so both are read.
97
+ printedMedium: stamp?.medium ?? stamp?.kind,
98
+ printedAt: stamp?.printed_at,
99
+ };
100
+ }
101
+ function rowsOf(data) {
102
+ if (Array.isArray(data))
103
+ return data;
104
+ const obj = (data ?? {});
105
+ for (const key of ['documents', 'threads', 'data', 'items']) {
106
+ if (Array.isArray(obj[key]))
107
+ return obj[key];
108
+ }
109
+ return [];
110
+ }
111
+ /**
112
+ * Every source on a face that a build could name.
113
+ *
114
+ * `include_uploads` matters: imported corpus rooms are excluded by default, and
115
+ * they are the main thing a style build is made from.
116
+ */
117
+ export async function listSelectable(client, alias) {
118
+ const a = encodeURIComponent(alias);
119
+ const [docs, threads] = await Promise.all([
120
+ client.get(`/v1/compile/documents?alias=${a}`),
121
+ client.get(`/v1/compile/threads?alias=${a}&include_uploads=true`),
122
+ ]);
123
+ const out = [
124
+ ...rowsOf(docs).map((r) => rowToSelectable(r, 'document')),
125
+ ...rowsOf(threads).map((r) => rowToSelectable(r, 'room')),
126
+ ];
127
+ return out.filter((s) => s.id);
128
+ }
129
+ /**
130
+ * Parse one `--source` value: `<id>` or `<id>:<medium>`.
131
+ *
132
+ * Ids are UUIDs, so the first colon separates cleanly and a medium containing
133
+ * spaces ("academic paper") survives intact.
134
+ */
135
+ export function parseSourceArg(raw) {
136
+ const at = raw.indexOf(':');
137
+ if (at === -1)
138
+ return { id: raw.trim() };
139
+ return { id: raw.slice(0, at).trim(), medium: raw.slice(at + 1).trim() || undefined };
140
+ }
141
+ /**
142
+ * Poll a voiceprint job until it stops.
143
+ *
144
+ * Returns the terminal payload including a failed one — whether a failure is an
145
+ * error is the caller's decision, not this function's.
146
+ */
147
+ export async function pollStyleJob(client, basePath, jobId, opts = {}) {
148
+ const interval = opts.intervalMs ?? 3000;
149
+ const timeout = opts.timeoutMs ?? 3_600_000;
150
+ const start = Date.now();
151
+ const endpoint = `${basePath}/${encodeURIComponent(jobId)}`;
152
+ while (true) {
153
+ if (Date.now() - start > timeout) {
154
+ throw new Error(`Timed out after ${Math.round(timeout / 1000)}s. The build may still finish; read it later with: faces style:status ${jobId}`);
155
+ }
156
+ await new Promise((r) => setTimeout(r, interval));
157
+ let data;
158
+ try {
159
+ data = (await client.get(endpoint));
160
+ }
161
+ catch (err) {
162
+ // A 5xx mid-poll is transient. Anything else is real and propagates.
163
+ if (err instanceof FacesAPIError && err.statusCode >= 500)
164
+ continue;
165
+ throw err;
166
+ }
167
+ opts.onPoll?.(data);
168
+ const status = String(data.status ?? '');
169
+ if (status === 'done' || status === 'failed')
170
+ return data;
171
+ }
172
+ }
173
+ /** Failure text, with the one hint that is actionable rather than descriptive. */
174
+ export function jobFailureMessage(error) {
175
+ const e = typeof error === 'string' && error ? error : 'unknown error';
176
+ if (e.toUpperCase().startsWith('CHATGPT_AUTH')) {
177
+ return (`${e}\n` +
178
+ 'The ChatGPT link has expired. Reconnect it with: faces auth:connect openai\n' +
179
+ 'Your uploaded material is preserved, so re-running costs nothing extra. Do not switch to a ' +
180
+ 'paid model to get around this: it turns a login problem into a bill.');
181
+ }
182
+ return e;
183
+ }
184
+ /**
185
+ * Fields printed from a build report, and the only ones.
186
+ *
187
+ * A whitelist rather than a filter, because the report carries internal
188
+ * analysis keys that must not reach a user's screen and this package ships its
189
+ * compiled source to npm. Naming the unwanted keys in order to strip them would
190
+ * publish them; naming only the wanted ones cannot.
191
+ */
192
+ const REPORT_FIELDS = [
193
+ ['sources_printed', 'sources printed'],
194
+ ['corpus_rooms', 'corpus rooms'],
195
+ ['voice_map_chars', 'voice map size'],
196
+ ['best_of_n', 'best of n'],
197
+ ];
198
+ /**
199
+ * Report keys that may leave this CLI, in --json as much as on screen.
200
+ *
201
+ * The report also carries internal analysis keys. Those are excluded by being
202
+ * absent from this list rather than by being named and removed: this package
203
+ * publishes its compiled source to npm, so naming them in order to strip them
204
+ * would publish them.
205
+ */
206
+ const REPORT_PUBLIC_KEYS = new Set([
207
+ 'sources_printed',
208
+ 'corpus_rooms',
209
+ 'voice_map_chars',
210
+ 'best_of_n',
211
+ 'components',
212
+ 'analyze',
213
+ 'intake',
214
+ 'baseline',
215
+ 'holdout',
216
+ 'scoring_enabled',
217
+ 'source_compile',
218
+ 'voicemap_compile',
219
+ 'tuning',
220
+ 'reused_lenses',
221
+ ]);
222
+ /**
223
+ * A job payload safe to hand onward, with its report reduced to public fields.
224
+ *
225
+ * Applied to --json too. A machine consumer reading our output is as much a way
226
+ * for an internal key to escape as a terminal is.
227
+ */
228
+ export function publicJob(data) {
229
+ const report = data.report;
230
+ if (!report || typeof report !== 'object')
231
+ return data;
232
+ const clean = {};
233
+ for (const [k, v] of Object.entries(report)) {
234
+ if (REPORT_PUBLIC_KEYS.has(k))
235
+ clean[k] = v;
236
+ }
237
+ return { ...data, report: clean };
238
+ }
239
+ /** Human-readable build summary. Never dumps the report. */
240
+ export function formatBuildReport(data) {
241
+ const report = (data.report ?? {});
242
+ const pairs = [
243
+ ['job', data.job_id ?? '-'],
244
+ ['status', data.status ?? '-'],
245
+ ];
246
+ if (data.version_id)
247
+ pairs.push(['version', data.version_id]);
248
+ for (const [key, label] of REPORT_FIELDS) {
249
+ const v = report[key];
250
+ if (v !== undefined && v !== null)
251
+ pairs.push([label, v]);
252
+ }
253
+ const analyze = report.analyze;
254
+ if (analyze?.chunks !== undefined)
255
+ pairs.push(['chunks', analyze.chunks]);
256
+ const width = Math.max(...pairs.map(([label]) => label.length)) + 2;
257
+ return pairs.map(([label, v]) => `${(label + ':').padEnd(width)}${v}`);
258
+ }