faces-cli 1.7.14 → 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.
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
+ }