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
|
@@ -2,6 +2,7 @@ import { BaseCommand } from '../../../base.js';
|
|
|
2
2
|
export default class CompileDocList extends BaseCommand {
|
|
3
3
|
static description: string;
|
|
4
4
|
static flags: {
|
|
5
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
5
6
|
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
7
|
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
8
|
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { Args } from '@oclif/core';
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../../../base.js';
|
|
3
3
|
import { FacesAPIError } from '../../../client.js';
|
|
4
4
|
export default class CompileDocList extends BaseCommand {
|
|
5
|
-
static description =
|
|
5
|
+
static description = "List documents for a face. Each document's full text is omitted unless --verbose, so a face with a " +
|
|
6
|
+
'real corpus stays readable. For a table of every source, documents and threads together, use face:sources.';
|
|
6
7
|
static flags = {
|
|
7
8
|
...BaseCommand.baseFlags,
|
|
9
|
+
verbose: Flags.boolean({ description: "Include each document's full text", default: false }),
|
|
8
10
|
};
|
|
9
11
|
static args = {
|
|
10
12
|
face_id: Args.string({ description: 'Face alias', required: true }),
|
|
@@ -21,8 +23,21 @@ export default class CompileDocList extends BaseCommand {
|
|
|
21
23
|
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
22
24
|
throw err;
|
|
23
25
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
// --json is untouched: machine callers may already read `content`, and
|
|
27
|
+
// dropping a field from structured output would break them silently.
|
|
28
|
+
if (!this.jsonEnabled()) {
|
|
29
|
+
const items = Array.isArray(data) ? data : [];
|
|
30
|
+
const omitted = flags.verbose ? 0 : items.filter((d) => typeof d.content === 'string' && d.content !== '').length;
|
|
31
|
+
this.printHuman(flags.verbose
|
|
32
|
+
? data
|
|
33
|
+
: items.map((d) => Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'content'))));
|
|
34
|
+
// Say that something was left out. A list that quietly drops a field
|
|
35
|
+
// reads as complete, which is how this became a problem in the first place.
|
|
36
|
+
if (omitted > 0) {
|
|
37
|
+
this.log('');
|
|
38
|
+
this.log(`Text omitted for ${omitted} document(s). Pass --verbose to include it.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
26
41
|
return data;
|
|
27
42
|
}
|
|
28
43
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class FaceSources extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
type: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
static args: {
|
|
12
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
13
|
+
};
|
|
14
|
+
run(): Promise<unknown>;
|
|
15
|
+
private fetch;
|
|
16
|
+
private statusOf;
|
|
17
|
+
private toRow;
|
|
18
|
+
private toJson;
|
|
19
|
+
private render;
|
|
20
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
/** Exit code for "no such face", so a caller can tell it from an empty face. */
|
|
5
|
+
const EXIT_NO_SUCH_FACE = 4;
|
|
6
|
+
export default class FaceSources extends BaseCommand {
|
|
7
|
+
static description = 'List every source compiled into a face: documents and threads together, one row each. ' +
|
|
8
|
+
`Exits ${EXIT_NO_SUCH_FACE} if the face does not exist, which is a different answer from a face that ` +
|
|
9
|
+
'has no sources. Content is never printed; read a single source with compile:doc:get or compile:thread:get.';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> <%= command.id %> alice',
|
|
12
|
+
'<%= config.bin %> <%= command.id %> alice --type thread',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> alice --json',
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
...BaseCommand.baseFlags,
|
|
17
|
+
type: Flags.string({ description: 'Show only one kind of source', options: ['doc', 'thread'] }),
|
|
18
|
+
};
|
|
19
|
+
static args = {
|
|
20
|
+
alias: Args.string({ description: 'Face alias', required: true }),
|
|
21
|
+
};
|
|
22
|
+
async run() {
|
|
23
|
+
const { args, flags } = await this.parse(FaceSources);
|
|
24
|
+
const client = this.makeClient(flags);
|
|
25
|
+
const json = this.jsonEnabled();
|
|
26
|
+
const alias = encodeURIComponent(args.alias);
|
|
27
|
+
const wantDocs = flags.type !== 'thread';
|
|
28
|
+
const wantThreads = flags.type !== 'doc';
|
|
29
|
+
// A face that does not exist and a face with nothing in it are opposite
|
|
30
|
+
// answers. The API distinguishes them with a 404, so this must too — by
|
|
31
|
+
// message and by exit code, since a script only sees the latter.
|
|
32
|
+
const docs = wantDocs ? await this.fetch(client, `/v1/compile/documents?alias=${alias}`, args.alias) : [];
|
|
33
|
+
// include_uploads defaults to excluding upload-created threads. This command
|
|
34
|
+
// claims to list every source, so it asks for them explicitly.
|
|
35
|
+
const threads = wantThreads
|
|
36
|
+
? await this.fetch(client, `/v1/compile/threads?alias=${alias}&include_uploads=true`, args.alias)
|
|
37
|
+
: [];
|
|
38
|
+
const rows = [...docs.map((d) => this.toRow(d, 'doc')), ...threads.map((t) => this.toRow(t, 'thread'))];
|
|
39
|
+
rows.sort((a, b) => (b.updated || '').localeCompare(a.updated || '') || a.label.localeCompare(b.label));
|
|
40
|
+
if (json) {
|
|
41
|
+
return {
|
|
42
|
+
alias: args.alias,
|
|
43
|
+
documents: docs.map((d) => this.toJson(d, 'doc')),
|
|
44
|
+
threads: threads.map((t) => this.toJson(t, 'thread')),
|
|
45
|
+
totals: {
|
|
46
|
+
sources: rows.length,
|
|
47
|
+
tokens: rows.reduce((n, r) => n + r.tokens, 0),
|
|
48
|
+
not_synced: rows.filter((r) => !r.synced).length,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
this.render(args.alias, rows, flags.type);
|
|
53
|
+
return { alias: args.alias, sources: rows.length };
|
|
54
|
+
}
|
|
55
|
+
async fetch(client, path, alias) {
|
|
56
|
+
try {
|
|
57
|
+
const data = await client.get(path);
|
|
58
|
+
return (Array.isArray(data) ? data : (data.data ?? []));
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof FacesAPIError) {
|
|
62
|
+
if (err.statusCode === 404) {
|
|
63
|
+
this.error(`No face named '${alias}'. It does not exist, or is not owned by this account.`, {
|
|
64
|
+
exit: EXIT_NO_SUCH_FACE,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
statusOf(s) {
|
|
73
|
+
if (s.synced)
|
|
74
|
+
return 'synced';
|
|
75
|
+
const st = s.prepare_status;
|
|
76
|
+
const done = s.chunks_completed;
|
|
77
|
+
const total = s.chunks_total;
|
|
78
|
+
const progress = done !== null && done !== undefined && total ? ` ${done}/${total}` : '';
|
|
79
|
+
if (st)
|
|
80
|
+
return `${st}${progress}`;
|
|
81
|
+
return 'not compiled';
|
|
82
|
+
}
|
|
83
|
+
toRow(s, type) {
|
|
84
|
+
return {
|
|
85
|
+
type,
|
|
86
|
+
id: String(s.document_id ?? s.thread_id ?? ''),
|
|
87
|
+
label: s.label?.trim() || '(untitled)',
|
|
88
|
+
tokens: s.token_count ?? 0,
|
|
89
|
+
status: this.statusOf(s),
|
|
90
|
+
medium: s.medium?.trim() || '-',
|
|
91
|
+
updated: (s.updated_at ?? s.created_at ?? '').slice(0, 10) || '-',
|
|
92
|
+
synced: Boolean(s.synced),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
toJson(s, type) {
|
|
96
|
+
const out = {
|
|
97
|
+
type,
|
|
98
|
+
// A stable id, because anything that later deletes or recompiles a source
|
|
99
|
+
// needs one — the label is for people and is not unique.
|
|
100
|
+
id: String(s.document_id ?? s.thread_id ?? ''),
|
|
101
|
+
label: s.label ?? null,
|
|
102
|
+
token_count: s.token_count ?? 0,
|
|
103
|
+
status: this.statusOf(s),
|
|
104
|
+
synced: Boolean(s.synced),
|
|
105
|
+
prepare_status: s.prepare_status ?? null,
|
|
106
|
+
medium: s.medium ?? null,
|
|
107
|
+
created_at: s.created_at ?? null,
|
|
108
|
+
updated_at: s.updated_at ?? null,
|
|
109
|
+
read_only: Boolean(s.read_only),
|
|
110
|
+
};
|
|
111
|
+
if (type === 'doc')
|
|
112
|
+
out.document_id = s.document_id;
|
|
113
|
+
if (type === 'thread') {
|
|
114
|
+
out.thread_id = s.thread_id;
|
|
115
|
+
out.message_count = s.message_count ?? null;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
render(alias, rows, type) {
|
|
120
|
+
if (rows.length === 0) {
|
|
121
|
+
const what = type ? `${type} sources` : 'sources';
|
|
122
|
+
this.log(`'${alias}' exists and has no ${what}.`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const n = (v) => v.toLocaleString('en-US');
|
|
126
|
+
const cols = [
|
|
127
|
+
['TYPE', (r) => r.type],
|
|
128
|
+
['LABEL', (r) => r.label],
|
|
129
|
+
['TOKENS', (r) => n(r.tokens)],
|
|
130
|
+
['STATUS', (r) => r.status],
|
|
131
|
+
['MEDIUM', (r) => r.medium],
|
|
132
|
+
['UPDATED', (r) => r.updated],
|
|
133
|
+
];
|
|
134
|
+
const widths = cols.map(([h, get]) => Math.max(h.length, ...rows.map((r) => get(r).length)));
|
|
135
|
+
const line = (cells) => cells.map((c, i) => (cols[i][0] === 'TOKENS' ? c.padStart(widths[i]) : c.padEnd(widths[i]))).join(' ').trimEnd();
|
|
136
|
+
this.log(line(cols.map(([h]) => h)));
|
|
137
|
+
for (const r of rows)
|
|
138
|
+
this.log(line(cols.map(([, get]) => get(r))));
|
|
139
|
+
// Say what the total counts. Every listed row is included, so the column
|
|
140
|
+
// adds up to the figure shown; anything not yet compiled is called out
|
|
141
|
+
// separately rather than quietly left out of the sum.
|
|
142
|
+
const tokens = rows.reduce((a, r) => a + r.tokens, 0);
|
|
143
|
+
const pending = rows.filter((r) => !r.synced).length;
|
|
144
|
+
this.log('');
|
|
145
|
+
this.log(`${n(tokens)} tokens across all ${rows.length} source(s) listed.`);
|
|
146
|
+
if (pending > 0) {
|
|
147
|
+
this.log(`${pending} not yet synced. Their tokens are included in that total.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleDelete extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
scope: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
};
|
|
12
|
+
static args: {
|
|
13
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
14
|
+
};
|
|
15
|
+
run(): Promise<unknown>;
|
|
16
|
+
private confirm;
|
|
17
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { BaseCommand } from '../../base.js';
|
|
4
|
+
import { FacesAPIError } from '../../client.js';
|
|
5
|
+
import { STYLE_FACES_PATH } from '../../style.js';
|
|
6
|
+
/** What each scope destroys, in the words the user needs before choosing. */
|
|
7
|
+
const SCOPES = {
|
|
8
|
+
map: 'the captured style and everything derived from it. Your uploaded material is kept, so it can be captured again without re-uploading.',
|
|
9
|
+
all: 'the captured style AND the material it was built from. The uploaded corpus is deleted. This cannot be undone and nothing can rebuild it.',
|
|
10
|
+
};
|
|
11
|
+
export default class StyleDelete extends BaseCommand {
|
|
12
|
+
static description = 'Delete a face\'s captured style. --scope map forgets the style and keeps the material it came from. ' +
|
|
13
|
+
'--scope all deletes the uploaded material too, permanently. There is no default because the two ' +
|
|
14
|
+
'differ by whether your material survives.';
|
|
15
|
+
static examples = [
|
|
16
|
+
'<%= config.bin %> <%= command.id %> alice --scope map',
|
|
17
|
+
'<%= config.bin %> <%= command.id %> alice --scope all --yes',
|
|
18
|
+
];
|
|
19
|
+
static flags = {
|
|
20
|
+
...BaseCommand.baseFlags,
|
|
21
|
+
scope: Flags.string({
|
|
22
|
+
description: 'map: forget the style, keep the material. all: delete the material with it, permanently.',
|
|
23
|
+
options: ['map', 'all'],
|
|
24
|
+
required: true,
|
|
25
|
+
}),
|
|
26
|
+
yes: Flags.boolean({ description: 'Skip confirmation', default: false }),
|
|
27
|
+
};
|
|
28
|
+
static args = {
|
|
29
|
+
alias: Args.string({ description: 'Face alias', required: true }),
|
|
30
|
+
};
|
|
31
|
+
async run() {
|
|
32
|
+
const { args, flags } = await this.parse(StyleDelete);
|
|
33
|
+
const client = this.makeClient(flags);
|
|
34
|
+
const scope = flags.scope;
|
|
35
|
+
if (!flags.yes) {
|
|
36
|
+
const what = `This deletes ${SCOPES[scope]}`;
|
|
37
|
+
if (this.jsonEnabled())
|
|
38
|
+
this.error(`${what}\nRe-run with --yes to confirm.`);
|
|
39
|
+
const ok = await this.confirm(`${what}\nDelete for '${args.alias}'?`);
|
|
40
|
+
if (!ok)
|
|
41
|
+
this.error('Aborted. Nothing was deleted.');
|
|
42
|
+
}
|
|
43
|
+
let data;
|
|
44
|
+
try {
|
|
45
|
+
data = await client.delete(`${STYLE_FACES_PATH}/${encodeURIComponent(args.alias)}?scope=${scope}`);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof FacesAPIError) {
|
|
49
|
+
if (err.statusCode === 404)
|
|
50
|
+
this.error(`No face named '${args.alias}'. It does not exist, or is not yours.`);
|
|
51
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
if (this.jsonEnabled())
|
|
56
|
+
return data;
|
|
57
|
+
this.log(scope === 'all'
|
|
58
|
+
? `Deleted the style and the uploaded material for '${args.alias}'.`
|
|
59
|
+
: `Deleted the style for '${args.alias}'. The material it came from is still there, so you can capture it again: faces style:make ${args.alias} --all`);
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
confirm(message) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
65
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
66
|
+
rl.close();
|
|
67
|
+
resolve(answer.toLowerCase() === 'y');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleMake extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
source: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
medium: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
model: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'allow-paid': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
compile: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'best-of': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'no-wait': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
};
|
|
19
|
+
static args: {
|
|
20
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
21
|
+
};
|
|
22
|
+
run(): Promise<unknown>;
|
|
23
|
+
private load;
|
|
24
|
+
/**
|
|
25
|
+
* Turn the flags into the wire's source list.
|
|
26
|
+
*
|
|
27
|
+
* The API wants {id, medium, source_type} per source and deliberately refuses
|
|
28
|
+
* to guess the medium. The listing already knows all three for most sources,
|
|
29
|
+
* so a user only has to supply what genuinely cannot be derived.
|
|
30
|
+
*/
|
|
31
|
+
private select;
|
|
32
|
+
/**
|
|
33
|
+
* Refuse a build that would bill without being asked to.
|
|
34
|
+
*
|
|
35
|
+
* The analyst model runs the whole build, so it is the most expensive call in
|
|
36
|
+
* the product. Models on the free tier run on the user's own linked ChatGPT
|
|
37
|
+
* subscription; anything else bills every time. Reaching for a paid model to
|
|
38
|
+
* work around an expired link turns a fixable login into a permanent cost,
|
|
39
|
+
* which is why this is a refusal and not a warning.
|
|
40
|
+
*/
|
|
41
|
+
private checkBilling;
|
|
42
|
+
/** Translate a pre-flight refusal into the decision the user has to make. */
|
|
43
|
+
private reportStartFailure;
|
|
44
|
+
private awaitJob;
|
|
45
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../../base.js';
|
|
3
|
+
import { FacesAPIError } from '../../client.js';
|
|
4
|
+
import { codexEligible } from '../../routing.js';
|
|
5
|
+
import { BUILDS_PATH, DEFAULT_STYLE_MODEL, formatBuildReport, jobFailureMessage, listSelectable, parseSourceArg, pollStyleJob, publicJob, } from '../../style.js';
|
|
6
|
+
export default class StyleMake extends BaseCommand {
|
|
7
|
+
static description = 'Capture how a face writes, from its own material, and install it. This is style, not knowledge: ' +
|
|
8
|
+
'compile teaches a face what it knows, style teaches it how it sounds. Name the sources to learn ' +
|
|
9
|
+
'from with --source, or take everything ready with --all.';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> <%= command.id %> alice --all',
|
|
12
|
+
'<%= config.bin %> <%= command.id %> alice --source 1a2b3c4d --source 5e6f7a8b',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> alice --source 1a2b3c4d:essay',
|
|
14
|
+
'<%= config.bin %> <%= command.id %> alice --all --medium essay --no-wait',
|
|
15
|
+
];
|
|
16
|
+
static flags = {
|
|
17
|
+
...BaseCommand.baseFlags,
|
|
18
|
+
source: Flags.string({
|
|
19
|
+
description: 'A document or thread to learn from, by id (repeatable). Append :<medium> to declare what it ' +
|
|
20
|
+
'is, e.g. --source 1a2b3c4d:essay, when the source does not already say.',
|
|
21
|
+
multiple: true,
|
|
22
|
+
}),
|
|
23
|
+
all: Flags.boolean({
|
|
24
|
+
description: 'Use every source on the face that is ready. Anything skipped is listed.',
|
|
25
|
+
default: false,
|
|
26
|
+
}),
|
|
27
|
+
medium: Flags.string({
|
|
28
|
+
description: 'Medium for any selected source that does not declare one. Each medium is analysed on its own, ' +
|
|
29
|
+
'so an essay never teaches a rule about email.',
|
|
30
|
+
}),
|
|
31
|
+
model: Flags.string({ description: `Analyst model (default: ${DEFAULT_STYLE_MODEL})` }),
|
|
32
|
+
'allow-paid': Flags.boolean({
|
|
33
|
+
description: 'Permit a build that bills. Without this a build is free or it does not run. Needed for any ' +
|
|
34
|
+
'model outside the free tier, and to allow the paid route when a free quota is exhausted.',
|
|
35
|
+
default: false,
|
|
36
|
+
}),
|
|
37
|
+
compile: Flags.boolean({
|
|
38
|
+
description: 'Compile any named source that is not compiled yet, before capturing style. On by default. ' +
|
|
39
|
+
'--no-compile captures style from what is already compiled and never bills for compilation.',
|
|
40
|
+
default: true,
|
|
41
|
+
allowNo: true,
|
|
42
|
+
}),
|
|
43
|
+
'best-of': Flags.integer({ description: 'Generate N candidates and keep the best (1-5, default 1)' }),
|
|
44
|
+
'no-wait': Flags.boolean({ description: 'Start the build and return the job id without polling.', default: false }),
|
|
45
|
+
timeout: Flags.integer({ description: 'How long to wait, in seconds (default: 3600)', default: 3600 }),
|
|
46
|
+
};
|
|
47
|
+
static args = {
|
|
48
|
+
alias: Args.string({ description: 'Face alias', required: true }),
|
|
49
|
+
};
|
|
50
|
+
async run() {
|
|
51
|
+
const { args, flags } = await this.parse(StyleMake);
|
|
52
|
+
const client = this.makeClient(flags);
|
|
53
|
+
const json = this.jsonEnabled();
|
|
54
|
+
if (!flags.all && (!flags.source || flags.source.length === 0)) {
|
|
55
|
+
this.error('Name what to learn from: --source <id> (repeatable), or --all for everything ready.\n' +
|
|
56
|
+
`See what is available with: faces face:sources ${args.alias}`);
|
|
57
|
+
}
|
|
58
|
+
if (flags.all && flags.source?.length) {
|
|
59
|
+
this.error('Pass either --all or --source, not both.');
|
|
60
|
+
}
|
|
61
|
+
const available = await this.load(client, args.alias);
|
|
62
|
+
const { sources, skipped } = this.select(available, flags, args.alias);
|
|
63
|
+
const model = flags.model ?? DEFAULT_STYLE_MODEL;
|
|
64
|
+
await this.checkBilling(client, model, flags['allow-paid']);
|
|
65
|
+
const payload = {
|
|
66
|
+
self_face: args.alias,
|
|
67
|
+
model,
|
|
68
|
+
sources,
|
|
69
|
+
compile: flags.compile,
|
|
70
|
+
};
|
|
71
|
+
if (flags['allow-paid'])
|
|
72
|
+
payload.oauth_only = false;
|
|
73
|
+
if (flags['best-of'] !== undefined)
|
|
74
|
+
payload.best_of_n = flags['best-of'];
|
|
75
|
+
if (!json && skipped.length > 0) {
|
|
76
|
+
for (const line of skipped)
|
|
77
|
+
process.stderr.write(`skipped: ${line}\n`);
|
|
78
|
+
}
|
|
79
|
+
if (!json) {
|
|
80
|
+
process.stderr.write(`Capturing style for '${args.alias}' from ${sources.length} source(s) using ${model}.\n`);
|
|
81
|
+
}
|
|
82
|
+
let started;
|
|
83
|
+
try {
|
|
84
|
+
started = (await client.post(BUILDS_PATH, { body: payload }));
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
this.reportStartFailure(err);
|
|
88
|
+
}
|
|
89
|
+
const jobId = String(started.job_id ?? '');
|
|
90
|
+
if (!jobId)
|
|
91
|
+
this.error('The server accepted the build but returned no job id.');
|
|
92
|
+
if (flags['no-wait']) {
|
|
93
|
+
if (json)
|
|
94
|
+
return { ...started, sources, skipped };
|
|
95
|
+
this.log(`Build started: ${jobId}`);
|
|
96
|
+
this.log(`Read it with: faces style:status ${jobId}`);
|
|
97
|
+
return started;
|
|
98
|
+
}
|
|
99
|
+
const final = await this.awaitJob(client, jobId, flags.timeout, json);
|
|
100
|
+
if (final.status === 'failed')
|
|
101
|
+
this.error(`Build failed: ${jobFailureMessage(final.error)}`);
|
|
102
|
+
if (json)
|
|
103
|
+
return { ...publicJob(final), sources, skipped };
|
|
104
|
+
for (const line of formatBuildReport(final))
|
|
105
|
+
this.log(line);
|
|
106
|
+
this.log('');
|
|
107
|
+
this.log(`'${args.alias}' now writes in its own style. Try it: faces chat:chat ${args.alias} -m "..."`);
|
|
108
|
+
this.log(`Go back to the previous style: faces style:revert ${args.alias} --yes`);
|
|
109
|
+
return final;
|
|
110
|
+
}
|
|
111
|
+
async load(client, alias) {
|
|
112
|
+
try {
|
|
113
|
+
return await listSelectable(client, alias);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof FacesAPIError) {
|
|
117
|
+
if (err.statusCode === 404)
|
|
118
|
+
this.error(`No face named '${alias}'. It does not exist, or is not yours.`);
|
|
119
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Turn the flags into the wire's source list.
|
|
126
|
+
*
|
|
127
|
+
* The API wants {id, medium, source_type} per source and deliberately refuses
|
|
128
|
+
* to guess the medium. The listing already knows all three for most sources,
|
|
129
|
+
* so a user only has to supply what genuinely cannot be derived.
|
|
130
|
+
*/
|
|
131
|
+
select(available, flags, alias) {
|
|
132
|
+
const byId = new Map(available.map((s) => [s.id, s]));
|
|
133
|
+
const skipped = [];
|
|
134
|
+
let chosen;
|
|
135
|
+
if (flags.all) {
|
|
136
|
+
if (available.length === 0) {
|
|
137
|
+
this.error(`'${alias}' has no sources to learn from. Add some with faces compile:doc or faces style:upload.`);
|
|
138
|
+
}
|
|
139
|
+
chosen = [];
|
|
140
|
+
for (const s of available) {
|
|
141
|
+
// Imported material and documents go in whether or not they are
|
|
142
|
+
// compiled: compiling them is part of the build, which is what
|
|
143
|
+
// --compile is for, and a corpus is never compiled at upload time.
|
|
144
|
+
//
|
|
145
|
+
// A live thread is the exception. It compiles as its messages arrive
|
|
146
|
+
// and is billed per message, so sweeping an unfinished one would charge
|
|
147
|
+
// for turns the user did not send. Those wait to be named.
|
|
148
|
+
if (s.isCorpus || s.synced) {
|
|
149
|
+
if (!flags.compile && !s.synced) {
|
|
150
|
+
skipped.push(`${s.label} (${s.id}) is not compiled, and --no-compile means it would teach nothing.`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
chosen.push({ s });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
skipped.push(`${s.label} (${s.id}) is a thread that is still compiling. Name it with --source once it is done.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (chosen.length === 0) {
|
|
160
|
+
this.error(`Nothing on '${alias}' is ready to learn from.\n` +
|
|
161
|
+
(flags.compile
|
|
162
|
+
? 'Add material with faces style:upload or faces compile:doc, then try again.'
|
|
163
|
+
: '--no-compile only uses sources that are already compiled, and none are. Drop it to compile them as part of this build.'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
chosen = [];
|
|
168
|
+
const missing = [];
|
|
169
|
+
for (const raw of flags.source ?? []) {
|
|
170
|
+
const { id, medium } = parseSourceArg(raw);
|
|
171
|
+
const s = byId.get(id);
|
|
172
|
+
if (s)
|
|
173
|
+
chosen.push({ s, declared: medium });
|
|
174
|
+
else
|
|
175
|
+
missing.push(id);
|
|
176
|
+
}
|
|
177
|
+
if (missing.length > 0) {
|
|
178
|
+
this.error(`Not a source on '${alias}': ${missing.join(', ')}\n` +
|
|
179
|
+
`List what is there with: faces face:sources ${alias}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const undeclared = [];
|
|
183
|
+
const sources = [];
|
|
184
|
+
for (const { s, declared } of chosen) {
|
|
185
|
+
const medium = declared ?? flags.medium ?? s.medium;
|
|
186
|
+
if (!medium) {
|
|
187
|
+
undeclared.push(s);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
sources.push({ id: s.id, medium, source_type: s.sourceType });
|
|
191
|
+
}
|
|
192
|
+
if (undeclared.length > 0) {
|
|
193
|
+
const list = undeclared
|
|
194
|
+
.map((s) => ` ${s.id} ${s.label}${s.sourceType === 'room' ? ' (thread)' : ''}`)
|
|
195
|
+
.join('\n');
|
|
196
|
+
const first = undeclared[0];
|
|
197
|
+
const ways = [
|
|
198
|
+
' --medium email applies to every source that does not say',
|
|
199
|
+
` --source ${first.id}:email applies to just this one`,
|
|
200
|
+
];
|
|
201
|
+
// Only a document can record a medium on itself; a thread has no such field.
|
|
202
|
+
if (first.sourceType === 'document') {
|
|
203
|
+
ways.push(` faces compile:doc:edit ${first.id} --medium email records it permanently`);
|
|
204
|
+
}
|
|
205
|
+
this.error(`These sources do not say what sort of writing they are, and a style build will not guess:\n${list}\n\n` +
|
|
206
|
+
`Declare it, using the real medium rather than the example:\n${ways.join('\n')}\n\n` +
|
|
207
|
+
'A wrong declaration is worse than a missing one: a mislabelled source teaches the wrong voice ' +
|
|
208
|
+
'for that medium, and nothing afterwards says it happened.');
|
|
209
|
+
}
|
|
210
|
+
return { sources, skipped };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Refuse a build that would bill without being asked to.
|
|
214
|
+
*
|
|
215
|
+
* The analyst model runs the whole build, so it is the most expensive call in
|
|
216
|
+
* the product. Models on the free tier run on the user's own linked ChatGPT
|
|
217
|
+
* subscription; anything else bills every time. Reaching for a paid model to
|
|
218
|
+
* work around an expired link turns a fixable login into a permanent cost,
|
|
219
|
+
* which is why this is a refusal and not a warning.
|
|
220
|
+
*/
|
|
221
|
+
async checkBilling(client, model, allowPaid) {
|
|
222
|
+
if (allowPaid)
|
|
223
|
+
return;
|
|
224
|
+
const eligible = await codexEligible(client, model);
|
|
225
|
+
if (eligible === false) {
|
|
226
|
+
this.error(`'${model}' is not on the free tier, so this build would bill you.\n` +
|
|
227
|
+
`Use ${DEFAULT_STYLE_MODEL}, which runs on your linked ChatGPT account at no cost, ` +
|
|
228
|
+
'or re-run with --allow-paid if you meant to pay.\n' +
|
|
229
|
+
'If you are here because a build failed on authentication, reconnect instead: faces auth:connect openai');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Translate a pre-flight refusal into the decision the user has to make. */
|
|
233
|
+
reportStartFailure(err) {
|
|
234
|
+
if (!(err instanceof FacesAPIError))
|
|
235
|
+
throw err;
|
|
236
|
+
// Every one of these lands before the job exists, so nothing has started.
|
|
237
|
+
const hint = {
|
|
238
|
+
402: 'Add credit with: faces billing:topup',
|
|
239
|
+
403: 'That source belongs to another account.',
|
|
240
|
+
404: 'Check the id against: faces face:sources <alias>',
|
|
241
|
+
409: 'A source is compiling right now, or the face is locked. Wait for it to finish, or unlock the face.',
|
|
242
|
+
422: 'A named thread has messages that are not compiled yet. Compile it first, or pass --no-compile.',
|
|
243
|
+
};
|
|
244
|
+
const extra = hint[err.statusCode];
|
|
245
|
+
this.error(`Error (${err.statusCode}): ${err.message}${extra ? `\n${extra}` : ''}\nNothing was started.`);
|
|
246
|
+
}
|
|
247
|
+
async awaitJob(client, jobId, timeout, json) {
|
|
248
|
+
let last = '';
|
|
249
|
+
try {
|
|
250
|
+
return await pollStyleJob(client, BUILDS_PATH, jobId, {
|
|
251
|
+
timeoutMs: timeout * 1000,
|
|
252
|
+
onPoll: (d) => {
|
|
253
|
+
if (json)
|
|
254
|
+
return;
|
|
255
|
+
const p = d.progress;
|
|
256
|
+
const stage = p?.stage ?? String(d.status ?? '');
|
|
257
|
+
const key = `${stage}:${p?.done ?? ''}/${p?.total ?? ''}`;
|
|
258
|
+
if (key === last)
|
|
259
|
+
return;
|
|
260
|
+
last = key;
|
|
261
|
+
process.stderr.write(` ${stage}${p?.total ? ` ${p.done ?? 0}/${p.total}` : ''}\n`);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (err instanceof FacesAPIError)
|
|
267
|
+
this.error(`Error (${err.statusCode}): ${err.message}`);
|
|
268
|
+
this.error(err instanceof Error ? err.message : String(err));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.js';
|
|
2
|
+
export default class StyleRevert extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
static args: {
|
|
12
|
+
alias: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
13
|
+
};
|
|
14
|
+
run(): Promise<unknown>;
|
|
15
|
+
}
|