ainize 0.1.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/dist/bin.d.ts +10 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +711 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/client.d.ts +38 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +164 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/commands/auth.d.ts +30 -0
  12. package/dist/commands/auth.d.ts.map +1 -0
  13. package/dist/commands/auth.js +216 -0
  14. package/dist/commands/auth.js.map +1 -0
  15. package/dist/commands/branch.d.ts +326 -0
  16. package/dist/commands/branch.d.ts.map +1 -0
  17. package/dist/commands/branch.js +467 -0
  18. package/dist/commands/branch.js.map +1 -0
  19. package/dist/commands/chain.d.ts +43 -0
  20. package/dist/commands/chain.d.ts.map +1 -0
  21. package/dist/commands/chain.js +224 -0
  22. package/dist/commands/chain.js.map +1 -0
  23. package/dist/commands/chat.d.ts +157 -0
  24. package/dist/commands/chat.d.ts.map +1 -0
  25. package/dist/commands/chat.js +395 -0
  26. package/dist/commands/chat.js.map +1 -0
  27. package/dist/commands/dataset.d.ts +51 -0
  28. package/dist/commands/dataset.d.ts.map +1 -0
  29. package/dist/commands/dataset.js +157 -0
  30. package/dist/commands/dataset.js.map +1 -0
  31. package/dist/commands/drive.d.ts +32 -0
  32. package/dist/commands/drive.d.ts.map +1 -0
  33. package/dist/commands/drive.js +68 -0
  34. package/dist/commands/drive.js.map +1 -0
  35. package/dist/commands/huggingface-dataset.d.ts +63 -0
  36. package/dist/commands/huggingface-dataset.d.ts.map +1 -0
  37. package/dist/commands/huggingface-dataset.js +254 -0
  38. package/dist/commands/huggingface-dataset.js.map +1 -0
  39. package/dist/commands/init.d.ts +107 -0
  40. package/dist/commands/init.d.ts.map +1 -0
  41. package/dist/commands/init.js +538 -0
  42. package/dist/commands/init.js.map +1 -0
  43. package/dist/commands/ledger.d.ts +43 -0
  44. package/dist/commands/ledger.d.ts.map +1 -0
  45. package/dist/commands/ledger.js +125 -0
  46. package/dist/commands/ledger.js.map +1 -0
  47. package/dist/commands/node.d.ts +250 -0
  48. package/dist/commands/node.d.ts.map +1 -0
  49. package/dist/commands/node.js +651 -0
  50. package/dist/commands/node.js.map +1 -0
  51. package/dist/commands/patch.d.ts +852 -0
  52. package/dist/commands/patch.d.ts.map +1 -0
  53. package/dist/commands/patch.js +1527 -0
  54. package/dist/commands/patch.js.map +1 -0
  55. package/dist/commands/peers.d.ts +86 -0
  56. package/dist/commands/peers.d.ts.map +1 -0
  57. package/dist/commands/peers.js +143 -0
  58. package/dist/commands/peers.js.map +1 -0
  59. package/dist/commands/teach-dataset.d.ts +381 -0
  60. package/dist/commands/teach-dataset.d.ts.map +1 -0
  61. package/dist/commands/teach-dataset.js +780 -0
  62. package/dist/commands/teach-dataset.js.map +1 -0
  63. package/dist/commands/teach.d.ts +351 -0
  64. package/dist/commands/teach.d.ts.map +1 -0
  65. package/dist/commands/teach.js +435 -0
  66. package/dist/commands/teach.js.map +1 -0
  67. package/dist/context.d.ts +55 -0
  68. package/dist/context.d.ts.map +1 -0
  69. package/dist/context.js +102 -0
  70. package/dist/context.js.map +1 -0
  71. package/dist/ens.d.ts +50 -0
  72. package/dist/ens.d.ts.map +1 -0
  73. package/dist/ens.js +155 -0
  74. package/dist/ens.js.map +1 -0
  75. package/dist/help.d.ts +57 -0
  76. package/dist/help.d.ts.map +1 -0
  77. package/dist/help.js +227 -0
  78. package/dist/help.js.map +1 -0
  79. package/dist/index.d.ts +15 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +15 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/output.d.ts +104 -0
  84. package/dist/output.d.ts.map +1 -0
  85. package/dist/output.js +383 -0
  86. package/dist/output.js.map +1 -0
  87. package/dist/pid.d.ts +5 -0
  88. package/dist/pid.d.ts.map +1 -0
  89. package/dist/pid.js +25 -0
  90. package/dist/pid.js.map +1 -0
  91. package/dist/quiet.d.ts +2 -0
  92. package/dist/quiet.d.ts.map +1 -0
  93. package/dist/quiet.js +12 -0
  94. package/dist/quiet.js.map +1 -0
  95. package/package.json +55 -0
@@ -0,0 +1,780 @@
1
+ /**
2
+ * `ainize teach dataset …` / `ainize teach train …` / `ainize teach jobs` — the FILE door of teach mode from the
3
+ * terminal (design docs/teachable-dataset-design.md §7.4).
4
+ *
5
+ * One pipeline, two doors: the browser collects corrections in chat and freezes them into a dataset file; here you
6
+ * hand the node a dataset file directly. Both produce the same dataset object and the same lesson, so a lesson
7
+ * taught in either door can be downloaded, re-trained or continued from its dataset.
8
+ *
9
+ * ainize teach dataset ./questions.csv --train # validate + upload, then train it
10
+ * ainize teach train <dataset-id> --effort thorough # train the same questions again
11
+ * ainize teach jobs # my lessons on this node, with the dataset each came from
12
+ * ainize teach dataset get <id> -o questions.jsonl # exactly what a lesson was trained on
13
+ *
14
+ * Every request is signed with a teaching key (§6.1). There is no account: the key is the identity. `--key-file`
15
+ * (the browser's backup JSON) or `--key` / `AINIZE_TEACH_KEY` chooses one; otherwise the CLI keeps one at
16
+ * `<AINIZE_HOME>/teaching-key.json` and creates it on first use — losing that file loses the lessons and earnings.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
20
+ import { basename, join, resolve } from 'node:path';
21
+ import { createIdentity, signMessage } from '@ainize/core';
22
+ import { NodeClient, query } from '../client.js';
23
+ import { CliError, PROG } from '../context.js';
24
+ import { c, emit, fmtBytes, fmtTime, info, kv, shortHash, table, warn } from '../output.js';
25
+ import { blockedText, knowledgeCell, loadTeacherKeyFor, nodeAddressOf, parseTeacherKey, privateDraftNote, renderTeachStatus, signedTeachHeader, TEACH_KEY_FILE } from './teach.js';
26
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
28
+ // ---------------------------------------------------------------- teaching key
29
+ /**
30
+ * The teaching key for this terminal: `--key-file` / `--key` / `AINIZE_TEACH_KEY`, else `<home>/teaching-key.json`,
31
+ * created (0600) on first use. `created` is true only when this call wrote the file — the caller says so out loud,
32
+ * because that file is the ONLY way back to the lessons and the earnings.
33
+ */
34
+ export function ensureTeacherKey(ctx, o = {}) {
35
+ const existing = loadTeacherKeyFor(ctx, o);
36
+ const path = join(ctx.home, TEACH_KEY_FILE);
37
+ if (existing)
38
+ return { key: existing, path: o.keyFile ?? (existsSync(path) ? path : null), created: false };
39
+ const id = createIdentity();
40
+ mkdirSync(ctx.home, { recursive: true });
41
+ writeFileSync(path, JSON.stringify({ kind: 'ainize-teaching-key', version: 1, privateKey: id.privateKey, address: id.address, created_at: Date.now() }, null, 2) + '\n', { mode: 0o600 });
42
+ return { key: parseTeacherKey(readFileSync(path, 'utf8')), path, created: true };
43
+ }
44
+ /** A node client that signs every teach request with the key (request-bound v2 header, one per request). */
45
+ export class TeachSession {
46
+ client;
47
+ key;
48
+ nodeAddress;
49
+ constructor(client, key, nodeAddress) {
50
+ this.client = client;
51
+ this.key = key;
52
+ this.nodeAddress = nodeAddress;
53
+ }
54
+ static async open(ctx, o = {}) {
55
+ const client = new NodeClient(ctx);
56
+ const { key, path, created } = ensureTeacherKey(ctx, o);
57
+ const nodeAddress = await nodeAddressOf(client);
58
+ if (created && path && !ctx.quiet && !ctx.json) {
59
+ process.stderr.write(c.warn('! ') + `new teaching key ${key.address} — kept in ${path}. Back it up: it is the only way back to these lessons and their earnings.\n`);
60
+ }
61
+ return new TeachSession(client, key, nodeAddress);
62
+ }
63
+ hdr(method, path, body) {
64
+ return { 'x-ainize-auth': signedTeachHeader(this.key, this.nodeAddress, method, path, body) };
65
+ }
66
+ get(path) { return this.client.get(path, { headers: this.hdr('GET', path), auth: false }); }
67
+ post(path, body) {
68
+ const payload = body ?? {};
69
+ return this.client.request(path, { method: 'POST', body: payload, headers: this.hdr('POST', path, JSON.stringify(payload)), auth: false });
70
+ }
71
+ del(path) { return this.client.request(path, { method: 'DELETE', headers: this.hdr('DELETE', path), auth: false }); }
72
+ /** Raw (non-JSON) GET — the dataset download. */
73
+ raw(path) { return this.client.get(path, { headers: this.hdr('GET', path), auth: false, raw: true }); }
74
+ /** Multipart upload: the v2 signature covers the sha256 header value, not the body (design §D14). */
75
+ upload(path, form, fileSha) {
76
+ return this.client.request(path, {
77
+ method: 'POST', body: form, timeoutMs: 300_000,
78
+ headers: { ...this.hdr('POST', path, fileSha), 'x-ainize-dataset-sha256': fileSha }, auth: false,
79
+ });
80
+ }
81
+ }
82
+ // ---------------------------------------------------------------- rendering
83
+ /** What the visitor is told about one source line. Only `ok` / `fixed` are trained. */
84
+ const ROW_COPY = {
85
+ ok: 'will train', fixed: 'will train (tidied up)', duplicate: 'same as an earlier line', conflict: 'two answers — pick one',
86
+ too_long: 'too long', empty: 'no question or no answer', blocked: 'not allowed on this node', not_parsed: 'could not be read',
87
+ over_cap: 'over this node\'s per-dataset limit',
88
+ };
89
+ const rowColor = (s) => (s === 'ok' ? c.ok(s) : s === 'fixed' || s === 'pii' ? c.warn(s) : c.err(s));
90
+ export function renderSummary(s) {
91
+ const bad = [];
92
+ const add = (n, w) => { if (n)
93
+ bad.push(`${n} ${w}`); };
94
+ add(s.duplicates, 'duplicate');
95
+ add(s.conflicts, 'contradicting');
96
+ add(s.too_long, 'too long');
97
+ add(s.empty, 'empty');
98
+ add(s.blocked, 'not allowed');
99
+ add(s.not_parsed, 'unreadable');
100
+ add(s.over_cap, 'over the limit');
101
+ const head = `${s.accepted} of ${s.source_rows} lines will train${s.fixed ? ` (${s.fixed} tidied up)` : ''}`;
102
+ const pii = s.pii ? ` · ${s.pii} look like personal information (they train; the training set cannot be published above "private" until they are removed)` : '';
103
+ return (bad.length ? `${head} · not used: ${bad.join(', ')}` : head) + pii;
104
+ }
105
+ /** The per-line problems — every line that will NOT train, with its source line number and the reason. */
106
+ export function renderRows(rows, opts = {}) {
107
+ // `pii` rows train, but they are shown with the problems: the owner has to remove them before the set can be shared
108
+ const shown = opts.all ? rows : rows.filter((r) => r.status !== 'ok' && r.status !== 'fixed');
109
+ if (!shown.length)
110
+ return c.dim(opts.all ? '(no questions)' : 'every line will train');
111
+ // item 5: after an edit the accepted rows are numbered by position, but a CARRIED row still points at the line of
112
+ // the uploaded file it came from. Marking it is the difference between two different sevens and one.
113
+ const carried = shown.some((r) => r.carried);
114
+ const out = table(shown, [
115
+ // after an edit the dataset was rewritten: these are positions in it, not lines of the file that was uploaded
116
+ { key: 'l', title: opts.positions ? '#' : 'LINE', get: (r) => `${r.line}${r.carried ? '*' : ''}`, align: 'right' },
117
+ { key: 's', title: 'STATUS', get: (r) => rowColor(r.status) },
118
+ { key: 'q', title: 'QUESTION', get: (r) => (r.prompt ?? r.raw ?? '').replace(/\s+/g, ' ').slice(0, 36) },
119
+ { key: 'w', title: 'WHY', get: (r) => (r.detail ?? (r.fixes?.length ? `tidied up: ${r.fixes.join(', ')}` : ROW_COPY[r.status] ?? r.status)).slice(0, 60) },
120
+ ]);
121
+ return carried && opts.positions
122
+ ? `${out}\n${c.dim('* a line of the file you uploaded, left out when it was read and not resolved by your edits since')}`
123
+ : out;
124
+ }
125
+ export function renderDataset(d, node) {
126
+ const file = [d.source_name, d.format, d.delimiter ? `separator ${JSON.stringify(d.delimiter)}` : '', d.has_header ? 'header row' : d.has_header === false ? 'no header row' : '', d.encoding, d.layout].filter(Boolean).join(' · ');
127
+ const pairs = [
128
+ ['dataset', c.id(d.id)],
129
+ ['questions', `${d.rows.toLocaleString('en-US')} kept${d.invalid_rows ? ` · ${d.invalid_rows} lines not used` : ''}`],
130
+ ['fingerprint', `${shortHash(d.sha256, 16)} (revision ${d.revision})`],
131
+ ['where it came from', d.source === 'upload' ? `a file you uploaded${file ? ` — ${file}` : ''}` : d.source === 'chat' ? 'corrections you collected in chat' : d.source === 'sample' ? 'an example dataset of this node' : 'the questions of a lesson taught before datasets existed'],
132
+ ['size', `${fmtBytes(d.size_bytes)}${d.source_bytes ? ` (uploaded ${fmtBytes(d.source_bytes)})` : ''}`],
133
+ ['state', d.deleted_at ? c.err('deleted') : d.status === 'in_use' ? c.warn('a lesson is training from it') : d.status === 'staged' ? 'never trained yet' : c.ok('ready')],
134
+ ['kept', d.deleted_at ? `deleted ${fmtTime(d.deleted_at)} — the questions are gone from this node`
135
+ // the sweep already ran: the row survives (name, fingerprint, lessons) but the questions do not
136
+ : d.size_bytes === 0 && d.rows > 0 ? 'the questions were deleted when its lesson finished, as you asked — it cannot be downloaded or trained again'
137
+ : d.retention === 'delete_after_training' ? 'deleted as soon as its lesson finishes' : `until ${fmtTime(d.expires_at)}`],
138
+ ];
139
+ if (d.parent_dataset)
140
+ pairs.push(['copied from', d.parent_dataset]);
141
+ if (d.job_ids.length)
142
+ pairs.push(['lessons', d.job_ids.join(', ')]);
143
+ if (d.summary.shared_ending)
144
+ pairs.push(['heads-up', `${d.summary.shared_ending} questions end the same way — the model may answer them all alike`]);
145
+ return [c.bold(d.name) + c.dim(` ${node}`), kv(pairs)].join('\n');
146
+ }
147
+ const nextSteps = (id) => c.dim([
148
+ `train it: ${PROG} teach train ${id} --effort balanced`,
149
+ `see it: ${PROG} teach dataset get ${id}`,
150
+ `download it: ${PROG} teach dataset get ${id} -o questions.jsonl`,
151
+ ].join('\n'));
152
+ /** How long `--wait` waits: `--timeout <minutes>`, an hour by default. */
153
+ export function waitMs(opts) {
154
+ const m = opts.timeout;
155
+ if (m === undefined)
156
+ return 60 * 60_000;
157
+ if (!Number.isFinite(m) || m <= 0)
158
+ throw new CliError('--timeout is in minutes and must be a positive number');
159
+ return Math.round(m * 60_000);
160
+ }
161
+ /**
162
+ * Validate a dataset file and upload it. Nothing is trained here — the node parses the bytes, reports every line it
163
+ * could not use, and keeps the questions as a dataset you can train (`--train`, or `teach train <id>`).
164
+ * Re-uploading the same file returns the SAME dataset (200, `created: false`) instead of a second copy.
165
+ */
166
+ export async function datasetUpload(ctx, file, opts = {}) {
167
+ const path = resolve(file);
168
+ if (!existsSync(path) || !statSync(path).isFile())
169
+ throw new CliError(`dataset file not found: ${file}`);
170
+ const bytes = readFileSync(path);
171
+ if (!bytes.length)
172
+ throw new CliError(`${basename(path)} is empty — a dataset needs at least one question and its answer`);
173
+ const s = await TeachSession.open(ctx, opts);
174
+ const fileSha = sha256(bytes);
175
+ const form = new FormData();
176
+ form.append('file', new Blob([new Uint8Array(bytes)]), basename(path));
177
+ const put = (k, v) => { if (v !== undefined)
178
+ form.append(k, v); };
179
+ put('name', opts.name);
180
+ put('retention', opts.retention);
181
+ put('format', opts.format);
182
+ put('delimiter', opts.delimiter);
183
+ put('encoding', opts.encoding);
184
+ put('layout', opts.layout);
185
+ if (opts.header !== undefined)
186
+ form.append('has_header', String(opts.header));
187
+ if (opts.columns) {
188
+ try {
189
+ JSON.parse(opts.columns);
190
+ }
191
+ catch {
192
+ throw new CliError('--columns must be JSON, e.g. \'{"prompt":0,"answer":2}\' or \'{"prompt":"질문","answer":"답"}\'');
193
+ }
194
+ form.append('columns', opts.columns);
195
+ }
196
+ const r = await s.upload('/api/teach/datasets', form, fileSha).catch((e) => { throw withReport(e, ctx); });
197
+ const out = { ...r, node: s.client.baseUrl, file: path, bytes: bytes.length, sha256: fileSha };
198
+ if (opts.train) {
199
+ out.job = await trainDataset(s, r.dataset.id, opts);
200
+ // Item 252: the documented one-liner (`teach dataset ./questions.csv --train`) is the form a cron line reaches
201
+ // for, and it was the one form that could not block on the result — `--wait` existed only on the sibling
202
+ // command, so the first scripted attempt failed with `Unknown argument: wait`. Same wait, same exit codes.
203
+ if (opts.wait) {
204
+ const done = await waitForJob(s, out.job.job.id, ctx, waitMs(opts));
205
+ out.job = { ...out.job, job: done };
206
+ out.waited = true;
207
+ process.exitCode = exitForJob(done);
208
+ if (unchecked(done))
209
+ warnUnchecked(ctx, done.id);
210
+ }
211
+ }
212
+ if (!opts.silent)
213
+ emit(ctx, out, (d) => renderUpload(d, { nextSteps: opts.nextSteps, waited: d.waited }));
214
+ return out;
215
+ }
216
+ export function renderUpload(r, opts = {}) {
217
+ const lines = [
218
+ renderDataset(r.dataset, r.node),
219
+ '',
220
+ (r.created ? c.ok('✓ ') : c.dim('· ')) + (r.created ? `uploaded ${basename(r.file)} (${fmtBytes(r.bytes)})` : `${basename(r.file)} is already on this node — same questions, same dataset, no second copy`),
221
+ renderSummary(r.report.summary),
222
+ ];
223
+ const bad = r.report.rows.filter((x) => x.status !== 'ok' && x.status !== 'fixed');
224
+ if (bad.length) {
225
+ lines.push('', c.head('lines that will not train'), renderRows(r.report.rows));
226
+ if (r.report.rows.length >= 50)
227
+ lines.push(c.dim(`the first 50 source lines only — the rest: ${PROG} teach dataset get ${r.dataset.id} --rows 200 --all`));
228
+ }
229
+ if (r.job && opts.waited)
230
+ lines.push('', renderTeachStatus({ kind: 'job', node: r.node, job: r.job.job, owner: true }));
231
+ else if (r.job)
232
+ lines.push('', renderJobCreated(r.job, r.node));
233
+ else if (opts.nextSteps !== false)
234
+ lines.push('', nextSteps(r.dataset.id));
235
+ return lines.join('\n');
236
+ }
237
+ /**
238
+ * A refused upload still carries the per-line report (`dataset_empty` / `dataset_format`): print it, so the terminal
239
+ * says WHICH lines it could not read instead of only that the file was no good (design G3).
240
+ */
241
+ function withReport(e, ctx) {
242
+ const err = e;
243
+ const report = err?.details?.report;
244
+ if (report?.rows?.length && !ctx.quiet && !ctx.json) {
245
+ process.stderr.write([renderSummary(report.summary), c.head('what the node read'), renderRows(report.rows, { all: true }), ''].join('\n') + '\n');
246
+ }
247
+ return e;
248
+ }
249
+ export async function datasetLs(ctx, opts = {}) {
250
+ const s = await TeachSession.open(ctx, opts);
251
+ const r = await s.get('/api/teach/datasets');
252
+ const out = { node: s.client.baseUrl, items: r.items };
253
+ emit(ctx, out, renderDatasetList);
254
+ return out;
255
+ }
256
+ export function renderDatasetList(r) {
257
+ return [
258
+ c.head(`your datasets on ${r.node}`),
259
+ table(r.items, [
260
+ { key: 'id', title: 'DATASET', get: (d) => c.id(d.id) },
261
+ { key: 'n', title: 'NAME', get: (d) => d.name.slice(0, 32) },
262
+ { key: 'q', title: 'QUESTIONS', get: (d) => String(d.rows), align: 'right' },
263
+ { key: 'r', title: 'REV', get: (d) => String(d.revision), align: 'right' },
264
+ { key: 'f', title: 'FINGERPRINT', get: (d) => shortHash(d.sha256, 10) },
265
+ { key: 'w', title: 'FROM', get: (d) => d.source },
266
+ { key: 'l', title: 'LESSONS', get: (d) => String(d.job_ids.length), align: 'right' },
267
+ { key: 's', title: 'STATE', get: (d) => (d.deleted_at ? c.err('deleted') : d.status === 'in_use' ? c.warn('training') : d.status) },
268
+ { key: 'k', title: 'KEPT UNTIL', get: (d) => fmtTime(d.expires_at) },
269
+ ], 'no datasets yet — upload one: ' + `${PROG} teach dataset ./questions.csv`),
270
+ ].join('\n');
271
+ }
272
+ /**
273
+ * One dataset: what it is, and every source line with the reason it was or was not used. With `-o` the canonical
274
+ * bytes are written to a file — re-uploading that file lands on the SAME dataset, which is what makes a lesson
275
+ * reproducible from its own questions.
276
+ */
277
+ export async function datasetGet(ctx, id, opts = {}) {
278
+ const s = await TeachSession.open(ctx, opts);
279
+ const { dataset } = await s.get(`/api/teach/datasets/${encodeURIComponent(id)}`);
280
+ const page = await s.get(`/api/teach/datasets/${encodeURIComponent(id)}/rows${query({ limit: Math.min(200, opts.rows ?? 50), offset: opts.offset, status: opts.status })}`);
281
+ const out = { node: s.client.baseUrl, dataset, page };
282
+ if (opts.out) {
283
+ const format = opts.format ?? 'jsonl';
284
+ const res = await s.raw(`/api/teach/datasets/${encodeURIComponent(id)}/download${query({ format })}`);
285
+ if (!res.ok)
286
+ throw new CliError(`download failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
287
+ const body = Buffer.from(await res.arrayBuffer());
288
+ writeFileSync(resolve(opts.out), body);
289
+ // the .jsonl bytes ARE the fingerprint subject; a .csv rendering of them is not
290
+ const verified = format === 'jsonl' ? sha256(body) === dataset.sha256 : sha256(body) === (res.headers.get('x-content-sha256') ?? '');
291
+ if (format === 'jsonl' && !verified)
292
+ warn(ctx, 'the downloaded bytes do not match the dataset fingerprint — do not re-upload this file');
293
+ out.saved = { path: resolve(opts.out), bytes: body.length, sha256: sha256(body), verified };
294
+ }
295
+ emit(ctx, out, (d) => renderDatasetGet(d, !!opts.all));
296
+ return out;
297
+ }
298
+ export function renderDatasetGet(r, all) {
299
+ const lines = [renderDataset(r.dataset, r.node)];
300
+ if (r.dataset.deleted_at) {
301
+ lines.push('', c.dim(`it held ${r.dataset.rows} questions; they were deleted, so they can no longer be read, downloaded or re-trained. The lessons trained from it are kept.`));
302
+ return lines.join('\n');
303
+ }
304
+ lines.push('', renderSummary(r.page.summary), '', c.head(all ? 'every line' : 'lines that will not train'), renderRows(r.page.items, { all, positions: r.dataset.revision > 1 }));
305
+ if (r.page.total > r.page.offset + r.page.items.length)
306
+ lines.push(c.dim(`${r.page.offset + r.page.items.length} of ${r.page.total} lines shown — more with --rows / --offset`));
307
+ if (r.saved)
308
+ lines.push('', c.ok('✓ ') + `saved ${r.saved.path} (${fmtBytes(r.saved.bytes)})` + (r.saved.verified ? c.dim(` · fingerprint verified — re-uploading it lands on this same dataset`) : ''));
309
+ return lines.join('\n');
310
+ }
311
+ // ---------------------------------------------------------------- teach dataset rm <id>
312
+ export async function datasetRm(ctx, id, opts = {}) {
313
+ const s = await TeachSession.open(ctx, opts);
314
+ await s.del(`/api/teach/datasets/${encodeURIComponent(id)}`);
315
+ const out = { node: s.client.baseUrl, deleted: id };
316
+ emit(ctx, out, (d) => c.ok('✓ ') + `dataset ${d.deleted} deleted. The lessons trained from it are kept — but they can no longer be re-trained from their questions.`);
317
+ return out;
318
+ }
319
+ /**
320
+ * `ainize patch fork <id>` — copy a published knowledge's questions into MY training sets, with that knowledge
321
+ * recorded as their parent. The next line is `teach train <dataset> --on <id>`, and the command says so.
322
+ */
323
+ export async function patchFork(ctx, id, opts = {}) {
324
+ const s = await TeachSession.open(ctx, opts);
325
+ const r = await s.post(`/api/patches/${encodeURIComponent(id)}/fork`, { ...(opts.name ? { name: opts.name } : {}) });
326
+ const out = { ...r, node: s.client.baseUrl };
327
+ emit(ctx, out, (d) => [
328
+ c.ok('✓ ') + (d.created ? `copied ${d.inherited_rows} question(s) from ${c.id(d.parent.patch_id)} into ${c.id(d.dataset_id)}` : `you already have this copy: ${c.id(d.dataset_id)}`),
329
+ kv([
330
+ ['dataset', `${d.dataset.name} · ${d.dataset.rows} question(s) · ${shortHash(d.dataset.sha256, 12)}`],
331
+ ['from', `${d.parent.name} (${d.parent.patch_id})${d.license ? ` · ${d.license}` : ''}`],
332
+ ['inherited', `${d.inherited_rows} — every one of them points at the question of ${d.parent.patch_id} it came from`],
333
+ ]),
334
+ c.dim(`add your own questions: ${PROG} teach dataset get ${d.dataset_id} -o questions.jsonl (edit, then re-upload)`),
335
+ c.dim(`teach on top of it: ${PROG} teach train ${d.dataset_id} --on ${d.parent.patch_id}`),
336
+ ].join('\n'));
337
+ return out;
338
+ }
339
+ // ---------------------------------------------------------------- teach train
340
+ const EFFORTS = ['quick', 'balanced', 'thorough'];
341
+ /** Build the job body from the training flags. `check` / `alt` are only sent when the caller actually set them. */
342
+ function trainingSpec(opts) {
343
+ const spec = {};
344
+ if (opts.effort) {
345
+ if (!EFFORTS.includes(opts.effort))
346
+ throw new CliError(`--effort must be one of ${EFFORTS.join(' | ')}`);
347
+ spec.effort = opts.effort;
348
+ }
349
+ if (opts.check !== undefined)
350
+ spec.check_side_effects = opts.check;
351
+ if (opts.alt !== undefined)
352
+ spec.use_alt = opts.alt;
353
+ if (opts.rows !== undefined) {
354
+ if (!Number.isInteger(opts.rows) || opts.rows < 1)
355
+ throw new CliError('--rows must be a whole number of questions (1 or more)');
356
+ spec.rows_limit = opts.rows;
357
+ }
358
+ return Object.keys(spec).length ? spec : undefined;
359
+ }
360
+ async function assertBasesUsable(s, ids) {
361
+ for (const id of ids) {
362
+ let e;
363
+ try {
364
+ e = await s.get(`/api/teach/bases/${encodeURIComponent(id)}`);
365
+ }
366
+ catch (err) {
367
+ // an older node has no such route: leave the check to the job post rather than refusing a valid command
368
+ if (err.exitCode === 2)
369
+ throw err;
370
+ if (/^HTTP 404|not found/i.test(err.message))
371
+ return;
372
+ throw err;
373
+ }
374
+ if (e.usable)
375
+ continue;
376
+ // Item 327: the file is here, but only because this node verified it — scoring is not a licence to build on it.
377
+ if (e.reason === 'not_licensed') {
378
+ const price = e.price && Number(e.price) > 0 ? `${e.price} ${e.currency ?? ''}`.trim() : 'free';
379
+ throw new CliError([
380
+ `${e.name ?? id} is on ${s.client.baseUrl}, but this node has not bought it — its file is here because this node verified it, and scoring a knowledge is not a licence to teach on top of it.`,
381
+ `Buy it first: \`${PROG} patch buy ${id}\` (${price}), then run this again.`,
382
+ ].join('\n'), 2);
383
+ }
384
+ if (e.reason === 'not_held') {
385
+ const price = e.price && Number(e.price) > 0 ? `${e.price} ${e.currency ?? ''}`.trim() : 'free';
386
+ throw new CliError([
387
+ `${e.name ?? id} is listed on ${s.client.baseUrl}, but its file is not on this node.`,
388
+ `Teaching on top of it needs the file: \`${PROG} use ${id} --no-apply\` (${price}), then run this again.`,
389
+ ].join('\n'), 2);
390
+ }
391
+ throw new CliError([
392
+ `${id} is not on ${s.client.baseUrl}.`,
393
+ `Check the id with \`${PROG} patch ls\`, or teach on a node that holds it (\`--node <url>\`).`,
394
+ ].join('\n'), 2);
395
+ }
396
+ }
397
+ async function trainDataset(s, datasetId, opts) {
398
+ const patchIds = (opts.patch ?? '').split(',').map((x) => x.trim()).filter(Boolean);
399
+ const baseIds = (opts.on ?? '').split(',').map((x) => x.trim()).filter(Boolean);
400
+ if (baseIds.length > 1)
401
+ throw new CliError(`--on takes one knowledge — combining two is \`${PROG} patch merge ${baseIds[0]} ${baseIds[1]}\``);
402
+ const body = {
403
+ dataset_id: datasetId,
404
+ // `--patch` alone keeps meaning "loaded for comparison"; with `--on` the base is what the lesson is built on and
405
+ // the rest is context (design §13). `builds_on_context` is only sent for the legacy shape, without a base.
406
+ patch_ids: patchIds, context_ids: patchIds, builds_on_context: !baseIds.length && patchIds.length > 0,
407
+ ...(baseIds.length ? { base_ids: baseIds, mode: 'extend' } : {}),
408
+ ...(opts.inherit === false ? { inherit: false } : {}),
409
+ ...(opts.yesChange ? { confirm_conflicts: true } : {}),
410
+ ...(opts.name ? { name: opts.name } : {}), ...(trainingSpec(opts) ? { training: trainingSpec(opts) } : {}),
411
+ ...(s.key.name ? { contributor: { name: s.key.name } } : {}),
412
+ };
413
+ return s.post('/api/teach/jobs', body);
414
+ }
415
+ /**
416
+ * `teach train <dataset-id | file>` — queue a lesson from a dataset. A path is uploaded first (the same validation
417
+ * `teach dataset` prints), so one command can go from a file on disk to a lesson.
418
+ */
419
+ export async function teachTrain(ctx, target, opts = {}) {
420
+ let uploaded;
421
+ let datasetId = target;
422
+ const s = await TeachSession.open(ctx, opts);
423
+ // Item 171: the bases are checked BEFORE the upload. Failing after the file is on the node — after a teaching key
424
+ // was created and a dataset quota was spent — is what made `invalid: unknown knowledge` unrecoverable advice.
425
+ await assertBasesUsable(s, [...(opts.on ?? '').split(','), ...(opts.patch ?? '').split(',')].map((x) => x.trim()).filter(Boolean));
426
+ if (!UUID_RE.test(target)) {
427
+ if (!existsSync(target))
428
+ throw new CliError(`not a dataset id or a file: ${target} — \`${PROG} teach dataset ls\` lists your datasets`);
429
+ uploaded = await datasetUpload(ctx, target, { ...opts, silent: ctx.json, nextSteps: false });
430
+ datasetId = uploaded.dataset.id;
431
+ if (!ctx.quiet && !ctx.json)
432
+ process.stdout.write('\n');
433
+ }
434
+ const created = await trainDataset(s, datasetId, opts);
435
+ let out = { ...created, node: s.client.baseUrl, dataset_id: datasetId, ...(uploaded ? { uploaded } : {}) };
436
+ if (opts.wait) {
437
+ const done = await waitForJob(s, created.job.id, ctx, waitMs(opts));
438
+ out = { ...out, job: done, ok: done.status === 'READY' && done.checks?.executed === true };
439
+ // Item 239: a script has to be able to tell a bake that worked from one that did not. `--wait` used to exit 0 on
440
+ // FAILED, on NEEDS_MORE and on a lesson that taught 0 of 18, so `teach train --wait && teach publish …` published
441
+ // a failed bake every morning. The exit code is the terminal status, documented in --help.
442
+ //
443
+ // Item 245: the READY-but-never-measured case had a code (8) and could not reach it — `EXIT_FOR_STATUS.READY`
444
+ // is 0, so the `??` fallback beside it was dead. A lesson saved unchecked because the model server was down
445
+ // cannot be published, and that is not a success.
446
+ process.exitCode = exitForJob(done);
447
+ if (unchecked(done)) {
448
+ warnUnchecked(ctx, done.id);
449
+ }
450
+ }
451
+ emit(ctx, out, (d) => (opts.wait ? renderTeachStatus({ kind: 'job', node: d.node, job: d.job, owner: true }) : renderJobCreated(d, d.node)));
452
+ return out;
453
+ }
454
+ /**
455
+ * `teach train --wait` exit codes (item 239). 0 only when the lesson is READY and was actually measured on the live
456
+ * model; every other terminal state has its own code so a cron line can branch instead of guessing from stdout.
457
+ */
458
+ export const EXIT_FOR_STATUS = {
459
+ READY: 0, NEEDS_MORE: 4, FAILED: 5, CANCELLED: 5, EXPIRED: 5, REJECTED: 6, ANNOUNCED: 0, PENDING_REVIEW: 0,
460
+ };
461
+ /** READY, and nothing was ever measured on the live model — the lesson exists and cannot be published (item 245). */
462
+ export const unchecked = (j) => j.status === 'READY' && j.checks?.executed !== true;
463
+ /** The exit code of a finished lesson: its terminal status, except that an unmeasured READY is 8, not 0. */
464
+ export function exitForJob(j) {
465
+ if (unchecked(j))
466
+ return 8;
467
+ return EXIT_FOR_STATUS[j.status] ?? 0;
468
+ }
469
+ /** What to do about a lesson that was saved unchecked — the recovery step that only the browser used to have. */
470
+ function warnUnchecked(ctx, jobId) {
471
+ warn(ctx, `this lesson was saved WITHOUT being measured on the live model (the model server was unavailable) — publishing it stays blocked until it is measured.`);
472
+ info(ctx, c.dim(` measure it now: ${PROG} teach recheck ${jobId} --wait`));
473
+ }
474
+ const TERMINAL = ['READY', 'NEEDS_MORE', 'FAILED', 'CANCELLED', 'EXPIRED', 'REJECTED', 'ANNOUNCED', 'PENDING_REVIEW'];
475
+ /** Poll one lesson until it stops moving, printing each stage change (`--wait`). */
476
+ async function waitForJob(s, id, ctx, timeoutMs = 60 * 60_000) {
477
+ const t0 = Date.now();
478
+ let last = '';
479
+ for (;;) {
480
+ const { job } = await s.get(`/api/teach/jobs/${encodeURIComponent(id)}`);
481
+ // Item 245 — with the model server down the stream printed `EXPORTED step 3/3 · 10/10 right` and then nothing at
482
+ // all for the whole grace period, while the node's own log said exactly what it was waiting for.
483
+ const line = `${job.status}${job.progress ? ` step ${job.progress.step}/${job.progress.max_steps} · ${job.progress.hits}/${job.progress.total} right` : ''}`
484
+ + (job.blocked ? ` · waiting for ${blockedText(job.blocked)}` : '');
485
+ if (line !== last && !ctx.quiet && !ctx.json) {
486
+ process.stderr.write(c.dim(` ${line}\n`));
487
+ last = line;
488
+ }
489
+ if (TERMINAL.includes(job.status))
490
+ return job;
491
+ if (Date.now() - t0 > timeoutMs) {
492
+ const waited = Math.round((Date.now() - t0) / 1000);
493
+ throw new CliError(`lesson ${id} is still ${job.status} after ${waited < 90 ? `${waited}s` : `${Math.round(waited / 60)} min`} — it goes on without this command; check later: ${PROG} teach status ${id}`, 7);
494
+ }
495
+ await new Promise((r) => setTimeout(r, 3000));
496
+ }
497
+ }
498
+ export function renderJobCreated(r, node) {
499
+ const q = r.quota ?? {};
500
+ const lines = [
501
+ c.ok('✓ ') + `lesson ${c.id(r.job.id)} queued` + (r.job.position !== undefined ? c.dim(` ${r.job.position} ahead of it`) : ''),
502
+ kv([
503
+ ['questions', `${r.job.facts?.length ?? r.job.dataset?.trained_rows ?? 0} of ${r.job.dataset?.rows ?? '?'} in the dataset`],
504
+ ...(r.job.bases?.length ? [['built on', `${r.job.bases[r.job.bases.length - 1].name ?? r.job.bases[r.job.bases.length - 1].patch_id}${r.job.inherited_rows ? ` · keeps ${r.job.inherited_rows} of its questions as known answers` : ''}${r.job.changed_rows ? ` · changes ${r.job.changed_rows} of its answers` : ''}`]] : []),
505
+ ['effort', r.job.training ? `${r.job.training.effort} · ${r.job.training.max_steps} passes${r.job.training.check_side_effects === false ? ' · side-effect check OFF (publishing stays blocked until it is measured)' : ''}` : c.dim('node default')],
506
+ ['left today', `${q.key_remaining ?? '?'} lessons · ${q.rows_remaining ?? '?'} questions (this key)`],
507
+ ]),
508
+ c.dim(`follow it: ${PROG} teach status ${r.job.id}`),
509
+ c.dim(`or in the browser: ${node}/teach/lesson/${r.job.id}`),
510
+ ];
511
+ return lines.join('\n');
512
+ }
513
+ export async function teachJobs(ctx, opts = {}) {
514
+ const s = await TeachSession.open(ctx, opts);
515
+ const r = await s.get('/api/teach/jobs');
516
+ const items = opts.dataset ? r.items.filter((j) => j.dataset?.id === opts.dataset) : r.items;
517
+ const out = { node: s.client.baseUrl, items };
518
+ emit(ctx, out, renderJobs);
519
+ return out;
520
+ }
521
+ export function renderJobs(r) {
522
+ return [
523
+ c.head(`your lessons on ${r.node}`),
524
+ table(r.items, [
525
+ { key: 'id', title: 'LESSON', get: (j) => c.id(j.id) },
526
+ { key: 'n', title: 'NAME', get: (j) => (j.name ?? j.facts?.[0]?.prompt ?? '-').slice(0, 30) },
527
+ { key: 's', title: 'STATUS', get: (j) => j.status },
528
+ { key: 'd', title: 'DATASET', get: (j) => (j.dataset?.id ? `${shortHash(j.dataset.id, 8)}${j.dataset.deleted ? c.err(' (deleted)') : ''}` : c.dim('none (v1 lesson)')) },
529
+ { key: 'q', title: 'QUESTIONS', get: (j) => (j.dataset ? `${j.dataset.trained_rows} / ${j.dataset.rows}` : String(j.facts?.length ?? 0)), align: 'right' },
530
+ { key: 'e', title: 'EFFORT', get: (j) => j.training?.effort ?? '-' },
531
+ // Item 184 — this column was titled PUBLISHED AS and printed the private draft id of a lesson that had
532
+ // published nothing; `patch get` on that id answers "patch not found", because a draft is invisible to
533
+ // everyone but the operator. The column says which of the two an id is.
534
+ { key: 'p', title: 'KNOWLEDGE', get: knowledgeCell },
535
+ { key: 't', title: 'UPDATED', get: (j) => fmtTime(j.updated_at) },
536
+ ], 'no lessons yet'),
537
+ privateDraftNote(r.items),
538
+ '',
539
+ c.dim(`one lesson: ${PROG} teach status <lesson-id> · its questions: ${PROG} teach dataset get <dataset-id>`),
540
+ ].filter(Boolean).join('\n');
541
+ }
542
+ // ---------------------------------------------------------------- teach recheck (item 245)
543
+ /**
544
+ * `ainize teach recheck <lesson>` — measure a lesson that was saved unchecked.
545
+ *
546
+ * When the serving model is unreachable the node trains anyway, waits out its grace period and then saves the
547
+ * lesson with `checks.executed: false`, which blocks publishing for good. `POST /api/teach/jobs/:id/recheck` has
548
+ * been the way back all along and existed only in the browser: an operator whose 3 a.m. cron produced an unchecked
549
+ * lesson had to open a page and click "check again". `--wait` follows it to its next terminal state, with the same
550
+ * exit codes `teach train --wait` uses.
551
+ */
552
+ export async function teachRecheck(ctx, jobId, opts = {}) {
553
+ const s = await TeachSession.open(ctx, opts);
554
+ await s.post(`/api/teach/jobs/${encodeURIComponent(jobId)}/recheck`)
555
+ .catch((e) => {
556
+ const msg = e.message;
557
+ if (/^job_not_ready/.test(msg))
558
+ throw new CliError(`${msg}\n ${PROG} teach status ${jobId} shows what state the lesson is in (only a READY or NEEDS_MORE lesson that was never measured can be re-checked).`, 1);
559
+ throw e;
560
+ });
561
+ info(ctx, c.dim(`${jobId} is queued for a re-check on the live model${opts.wait ? '' : ` — ${PROG} teach status ${jobId} follows it`}`));
562
+ const job = opts.wait
563
+ ? await waitForJob(s, jobId, ctx)
564
+ : (await s.get(`/api/teach/jobs/${encodeURIComponent(jobId)}`)).job;
565
+ if (opts.wait) {
566
+ process.exitCode = exitForJob(job);
567
+ if (unchecked(job))
568
+ warn(ctx, `still unmeasured: the model server did not answer this time either. ${PROG} teach recheck ${jobId} --wait tries again.`);
569
+ }
570
+ const out = { node: s.client.baseUrl, job };
571
+ emit(ctx, out, (d) => renderTeachStatus({ kind: 'job', node: d.node, job: d.job, owner: Array.isArray(d.job.facts) }));
572
+ return out;
573
+ }
574
+ /**
575
+ * `ainize teach publish <job-id>` — the door that only the browser had (item 238).
576
+ *
577
+ * The whole dataset pipeline already ran from the terminal (upload -> train -> wait -> READY) and then stopped with
578
+ * "open <node>/teach/lesson/<id>": publishing needs a `claim_sig` signed by the TEACHING key, and only the browser's
579
+ * PublishSheet ever signed one — although this CLI has held that key in `<home>/teaching-key.json` all along. So a
580
+ * cron line could bake every morning and never share anything without someone opening a browser at 3 a.m.
581
+ *
582
+ * The two consents are the publisher's, not this command's: they are typed as flags, sent as the real checkbox state,
583
+ * and the node refuses the publish without both. Nothing is defaulted to true.
584
+ */
585
+ export async function teachPublish(ctx, jobId, opts) {
586
+ if (!opts.consentPermanent || !opts.consentRights) {
587
+ throw new CliError([
588
+ 'publishing puts this lesson on a permanent public record: the questions, the answers, your display name and your payout address cannot be edited or deleted, and verifier nodes will read them.',
589
+ 'Confirm both, in your own words, with --consent-permanent --consent-rights (the second is: you have the right to share this information, and it is not private or personal data).',
590
+ ].join('\n'), 1);
591
+ }
592
+ const name = (opts.name ?? '').trim();
593
+ if (name.length < 2 || name.length > 80)
594
+ throw new CliError('--name must be 2 to 80 characters — it is what buyers see');
595
+ const price = (opts.price ?? '0').trim();
596
+ if (!/^\d+(\.\d+)?$/.test(price))
597
+ throw new CliError('--price must be a number, 0 or more (0 = free)');
598
+ const payout = opts.payout === undefined ? undefined : opts.payout === 'none' ? null : opts.payout.trim();
599
+ if (payout && !/^0x[0-9a-fA-F]{40}$/.test(payout))
600
+ throw new CliError('--payout takes an AIN address (0x…) or the word `none`');
601
+ const s = await TeachSession.open(ctx, opts);
602
+ const chPath = `/api/teach/jobs/${encodeURIComponent(jobId)}/publish-challenge${query({ payout_address: payout === null ? 'none' : payout })}`;
603
+ const challenge = await s.get(chPath).catch((e) => {
604
+ const msg = e.message;
605
+ // `job_not_ready` / `checks_failed` are the lesson's own state, not a usage error: exit 1 with what to do next
606
+ if (/^job_not_ready|^checks_failed/.test(msg))
607
+ throw new CliError(`${msg}\n${PROG} teach status ${jobId} shows what the lesson is waiting for.`, 1);
608
+ if (/^publish_disabled/.test(msg))
609
+ throw new CliError(`${msg}\nKeep the file instead: ${PROG} teach status ${jobId}`, 1);
610
+ throw e;
611
+ });
612
+ const claim_sig = signMessage(challenge.claim, s.key.privateKey);
613
+ const body = {
614
+ name, price, ...(opts.license ? { license: opts.license } : {}), ...(opts.description ? { description: opts.description } : {}),
615
+ ...(payout !== undefined ? { payout_address: payout } : {}),
616
+ claim_sig, consent: { permanent: true, rights: true },
617
+ dataset: {
618
+ access: opts.access ?? 'derivative', ...(opts.datasetLicense ? { license: opts.datasetLicense } : {}), ...(opts.includeNotes ? { include_notes: true } : {}),
619
+ // §6.5 — where the questions come from. The node demands it above `dataset.declarationRows`, so without this
620
+ // flag a big set could be trained from the terminal and never published from it. `no_pii` is not invented
621
+ // here: it is the second consent this command already refuses to run without, in the publisher's own words
622
+ // ("you have the right to share this information, and it is not private or personal data").
623
+ ...(opts.declare ? { declaration: { source: opts.declare, no_pii: true, ...(opts.datasetLicense ? { license: opts.datasetLicense } : {}) } } : {}),
624
+ },
625
+ ...(s.key.name ? { contributor: { name: s.key.name } } : {}),
626
+ };
627
+ const result = await s.post(`/api/teach/jobs/${encodeURIComponent(jobId)}/publish`, body);
628
+ const out = { node: s.client.baseUrl, job_id: jobId, challenge, result, price };
629
+ emit(ctx, out, renderPublished);
630
+ return out;
631
+ }
632
+ export function renderPublished(r) {
633
+ const ch = r.challenge;
634
+ const sp = ch.split_preview;
635
+ const cur = sp?.currency ?? ch.ledger?.currency ?? '';
636
+ const lines = [];
637
+ lines.push(r.result.status === 'ANNOUNCED'
638
+ ? c.ok('✓ ') + `published as ${c.id(r.result.patch_id)} (ANNOUNCED)`
639
+ : c.ok('✓ ') + 'sent to the node operator for review (PENDING_REVIEW)');
640
+ const pairs = [['price', Number(r.price) > 0 ? `${r.price} ${cur}` : 'free'], ['credited to', ch.address]];
641
+ // Item 186 in the terminal: the same numbers the sheet shows, from the node's own royaltySplit — never the raw
642
+ // contributor share, which is 70 % on a lesson that pays its teacher 49 %.
643
+ if (sp) {
644
+ const pctOf = (x) => `${Math.round(x * 1000) / 10} %`;
645
+ const amount = (x) => (Number(r.price) > 0 ? ` = ${Math.round(x * Number(r.price) * 1e6) / 1e6} ${cur}` : '');
646
+ for (const sh of sp.shares) {
647
+ if (sh.share <= 0 && sh.kind !== 'node')
648
+ continue;
649
+ const who = sh.kind === 'you' ? 'you' : sh.kind === 'node' ? 'this node' : sh.name ?? sh.address;
650
+ pairs.push([sh.kind === 'lineage' ? ' creator share' : sh.kind === 'you' ? ' your share' : ' node share', `${pctOf(sh.share)} of every sale${amount(sh.share)} ${c.dim(who)}`]);
651
+ }
652
+ if (sp.parents.length)
653
+ pairs.push(['built on', sp.parents.map((p) => `${p.name}${p.price ? ` (sells for ${p.price} ${cur})` : ''}`).join(', ')]);
654
+ }
655
+ if (ch.ledger?.kind === 'local')
656
+ pairs.push(['settles in', c.warn(`${ch.ledger.currency} on this node's own ledger`) + ' — development play money, not withdrawable']);
657
+ if (r.result.status === 'ANNOUNCED')
658
+ pairs.push(['page', r.result.url]);
659
+ lines.push(kv(pairs));
660
+ // Item 298: never end on "verifiers are now checking it" when this node has none.
661
+ const v = ch.verification;
662
+ if (v && v.verifiers < v.quorum) {
663
+ lines.push('', c.warn('! ') + `${r.node} has ${v.verifiers} verifier peer(s) and needs ${v.quorum}: this is on the record, but it cannot go on sale here until verifier nodes appear.`);
664
+ }
665
+ else if (v) {
666
+ lines.push('', c.dim(`${v.verifiers} verifier peer(s) reachable — it goes on sale when ${v.quorum} of them agree.`));
667
+ }
668
+ return lines.join('\n');
669
+ }
670
+ const TIERS = ['union', 'retrain', 'rebuild'];
671
+ const TIER_COPY = {
672
+ union: 'just combine — no training', retrain: 'retrain the disagreeing questions on top of both', rebuild: 'rebuild everything from the combined questions',
673
+ };
674
+ /**
675
+ * `ainize patch merge <a> <b>` — combine two knowledges (design §13).
676
+ *
677
+ * It always measures first and prints what it measured: how the two training sets overlap, how the two FILES overlap,
678
+ * and which of the three builds is possible. `--preview` stops there. Without a resolution for every question the two
679
+ * answer differently the command refuses and prints those questions as JSON on stdout with exit code 3 — that file,
680
+ * with an answer chosen for each key, is what `--resolve` takes back.
681
+ */
682
+ export async function patchMerge(ctx, a, b, opts = {}) {
683
+ const s = await TeachSession.open(ctx, opts);
684
+ await assertBasesUsable(s, [a, b]);
685
+ const preview = await s.post('/api/teach/merge/preview', { a, b });
686
+ const base = { node: s.client.baseUrl, preview, a, b };
687
+ if (opts.preview) {
688
+ emit(ctx, base, (d) => renderMergePreview(d.preview));
689
+ return base;
690
+ }
691
+ if (opts.tier && !TIERS.includes(opts.tier))
692
+ throw new CliError(`--tier must be one of ${TIERS.join(' | ')}`);
693
+ const tier = opts.tier ?? preview.tiers.required ?? (preview.tiers.union.allowed ? 'union' : preview.tiers.retrain.allowed ? 'retrain' : 'rebuild');
694
+ const resolutions = opts.resolve ? readResolutions(opts.resolve) : {};
695
+ const open = (preview.questions?.conflicts ?? []).filter((x) => resolutions[x.key] === undefined);
696
+ if (open.length) {
697
+ // stdout stays machine-readable on purpose: this file IS the input of `--resolve`
698
+ process.stdout.write(`${JSON.stringify(Object.fromEntries(open.map((x) => [x.key, { prompt: x.prompt, a_answer: x.a_answer, b_answer: x.b_answer, choose: 'a | b | drop | {"answer": "…"}' }])), null, 1)}\n`);
699
+ throw new CliError(`${open.length} question(s) are answered differently by ${a} and ${b}. Save the JSON above, put "a", "b", "drop" or {"answer": "…"} in place of each \`choose\`, and run again with --resolve <file>.`, 3, { conflicts: open });
700
+ }
701
+ const created = await s.post('/api/teach/jobs', {
702
+ patch_ids: [], base_ids: [a, b], mode: 'merge', tier, resolutions,
703
+ ...(opts.name ? { name: opts.name } : {}), ...(s.key.name ? { contributor: { name: s.key.name } } : {}),
704
+ });
705
+ let out = { ...base, ...created, tier };
706
+ if (opts.wait) {
707
+ const done = await waitForJob(s, created.job.id, ctx);
708
+ out = { ...out, job: done };
709
+ process.exitCode = EXIT_FOR_STATUS[done.status] ?? 0;
710
+ }
711
+ emit(ctx, out, (d) => [
712
+ renderMergePreview(d.preview),
713
+ '',
714
+ c.ok('✓ ') + `${TIER_COPY[d.tier ?? 'union']}: lesson ${c.id(d.job.id)} queued`,
715
+ c.dim(`watch it: ${PROG} teach status ${d.job.id}`),
716
+ c.dim(`publish it: ${PROG} teach publish ${d.job.id} --dataset-access derivative --dataset-license ${d.preview.licenses.child_min ?? 'CC-BY-4.0'}`),
717
+ ].join('\n'));
718
+ return out;
719
+ }
720
+ function readResolutions(path) {
721
+ if (!existsSync(path))
722
+ throw new CliError(`no such file: ${path}`);
723
+ let parsed;
724
+ try {
725
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
726
+ }
727
+ catch (e) {
728
+ throw new CliError(`${path} is not JSON: ${e.message}`);
729
+ }
730
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
731
+ throw new CliError(`${path} must be an object of {"<question key>": "a" | "b" | "drop" | {"answer": "…"}}`);
732
+ const out = {};
733
+ for (const [key, v] of Object.entries(parsed)) {
734
+ if (v === 'a' || v === 'b' || v === 'drop') {
735
+ out[key] = v;
736
+ continue;
737
+ }
738
+ const answer = v?.answer;
739
+ if (typeof answer === 'string' && answer.trim()) {
740
+ out[key] = { answer };
741
+ continue;
742
+ }
743
+ // a key still carrying the `choose` placeholder is not a choice — say which one, rather than sending it
744
+ throw new CliError(`no answer chosen for "${v?.prompt ?? key}" — put "a", "b", "drop" or {"answer": "…"} there`);
745
+ }
746
+ return out;
747
+ }
748
+ function renderMergePreview(p) {
749
+ // the design's copy names the two knowledges, never their ids: "{A} says: …" is a sentence a creator can read
750
+ const A = p.a.name || p.a.id;
751
+ const B = p.b.name || p.b.id;
752
+ const tier = (name) => {
753
+ const t = p.tiers[name];
754
+ const est = 'est_min' in t && t.est_min !== null ? c.dim(` ~${t.est_min} min`) : name === 'union' ? '' : c.dim(' (this node has never timed one)');
755
+ const mark = t.allowed ? c.ok('✓') : c.err('✗');
756
+ const why = t.allowed ? '' : c.dim(` — ${t.reason}`);
757
+ return ` ${mark} ${TIER_COPY[name]}${est}${why}${p.tiers.required === name ? c.warn(' ← required') : ''}`;
758
+ };
759
+ const rows = ['rows', `${p.rows.a_only} only in ${A} · ${p.rows.b_only} only in ${B} · ${p.rows.shared} written by both (${p.rows.disagree} disagree)`];
760
+ return [
761
+ `${A} ${c.dim(`(${p.a.id})`)} + ${B} ${c.dim(`(${p.b.id})`)}`,
762
+ p.questions
763
+ ? kv([
764
+ ['questions', `${p.questions.a_only} only in ${A} · ${p.questions.b_only} only in ${B} · ${p.questions.same} the same · ${p.questions.conflicts.length} same question, different answer`],
765
+ rows,
766
+ ['combined set', p.merged ? `${p.merged.rows} question(s) — ${p.merged.from_a} from ${A}, ${p.merged.from_b} from ${B}` : '—'],
767
+ ['licence', `${p.licenses.a ?? '—'} + ${p.licenses.b ?? '—'} → ${p.licenses.child_min ?? 'your choice'}`],
768
+ ])
769
+ : kv([
770
+ ['questions', c.dim(`${p.private_parent === p.a.id ? A : B} keeps its questions private — only the rows can be compared`)],
771
+ rows,
772
+ ]),
773
+ '',
774
+ tier('union'), tier('retrain'), tier('rebuild'),
775
+ ...(p.questions?.conflicts.length ? ['', c.warn(`${p.questions.conflicts.length} question(s) need an answer:`),
776
+ ...p.questions.conflicts.slice(0, 5).map((x) => ` ${x.prompt}\n ${A}: ${x.a_answer}\n ${B}: ${x.b_answer}`),
777
+ ...(p.questions.conflicts.length > 5 ? [c.dim(` … and ${p.questions.conflicts.length - 5} more`)] : [])] : []),
778
+ ].join('\n');
779
+ }
780
+ //# sourceMappingURL=teach-dataset.js.map