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/commands/compile/doc/list.d.ts +1 -0
- package/dist/commands/compile/doc/list.js +19 -4
- package/dist/commands/face/sources.d.ts +20 -0
- package/dist/commands/face/sources.js +150 -0
- package/dist/commands/style/delete.d.ts +17 -0
- package/dist/commands/style/delete.js +71 -0
- package/dist/commands/style/make.d.ts +45 -0
- package/dist/commands/style/make.js +271 -0
- package/dist/commands/style/revert.d.ts +15 -0
- package/dist/commands/style/revert.js +65 -0
- package/dist/commands/style/status.d.ts +19 -0
- package/dist/commands/style/status.js +102 -0
- package/dist/commands/style/upload.d.ts +18 -0
- package/dist/commands/style/upload.js +81 -0
- package/dist/commands/style/versions.d.ts +14 -0
- package/dist/commands/style/versions.js +77 -0
- package/dist/routing.d.ts +9 -0
- package/dist/routing.js +20 -0
- package/dist/style.d.ts +108 -0
- package/dist/style.js +258 -0
- package/oclif.manifest.json +865 -259
- package/package.json +4 -1
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
|
+
}
|