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.
- 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 +1286 -761
- package/package.json +4 -1
|
@@ -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
|
+
}
|
package/dist/style.d.ts
ADDED
|
@@ -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
|
+
}
|