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.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/dist/bin.d.ts +10 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +711 -0
- package/dist/bin.js.map +1 -0
- package/dist/client.d.ts +38 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +164 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/auth.d.ts +30 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +216 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/branch.d.ts +326 -0
- package/dist/commands/branch.d.ts.map +1 -0
- package/dist/commands/branch.js +467 -0
- package/dist/commands/branch.js.map +1 -0
- package/dist/commands/chain.d.ts +43 -0
- package/dist/commands/chain.d.ts.map +1 -0
- package/dist/commands/chain.js +224 -0
- package/dist/commands/chain.js.map +1 -0
- package/dist/commands/chat.d.ts +157 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +395 -0
- package/dist/commands/chat.js.map +1 -0
- package/dist/commands/dataset.d.ts +51 -0
- package/dist/commands/dataset.d.ts.map +1 -0
- package/dist/commands/dataset.js +157 -0
- package/dist/commands/dataset.js.map +1 -0
- package/dist/commands/drive.d.ts +32 -0
- package/dist/commands/drive.d.ts.map +1 -0
- package/dist/commands/drive.js +68 -0
- package/dist/commands/drive.js.map +1 -0
- package/dist/commands/huggingface-dataset.d.ts +63 -0
- package/dist/commands/huggingface-dataset.d.ts.map +1 -0
- package/dist/commands/huggingface-dataset.js +254 -0
- package/dist/commands/huggingface-dataset.js.map +1 -0
- package/dist/commands/init.d.ts +107 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +538 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/ledger.d.ts +43 -0
- package/dist/commands/ledger.d.ts.map +1 -0
- package/dist/commands/ledger.js +125 -0
- package/dist/commands/ledger.js.map +1 -0
- package/dist/commands/node.d.ts +250 -0
- package/dist/commands/node.d.ts.map +1 -0
- package/dist/commands/node.js +651 -0
- package/dist/commands/node.js.map +1 -0
- package/dist/commands/patch.d.ts +852 -0
- package/dist/commands/patch.d.ts.map +1 -0
- package/dist/commands/patch.js +1527 -0
- package/dist/commands/patch.js.map +1 -0
- package/dist/commands/peers.d.ts +86 -0
- package/dist/commands/peers.d.ts.map +1 -0
- package/dist/commands/peers.js +143 -0
- package/dist/commands/peers.js.map +1 -0
- package/dist/commands/teach-dataset.d.ts +381 -0
- package/dist/commands/teach-dataset.d.ts.map +1 -0
- package/dist/commands/teach-dataset.js +780 -0
- package/dist/commands/teach-dataset.js.map +1 -0
- package/dist/commands/teach.d.ts +351 -0
- package/dist/commands/teach.d.ts.map +1 -0
- package/dist/commands/teach.js +435 -0
- package/dist/commands/teach.js.map +1 -0
- package/dist/context.d.ts +55 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +102 -0
- package/dist/context.js.map +1 -0
- package/dist/ens.d.ts +50 -0
- package/dist/ens.d.ts.map +1 -0
- package/dist/ens.js +155 -0
- package/dist/ens.js.map +1 -0
- package/dist/help.d.ts +57 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +227 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/output.d.ts +104 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +383 -0
- package/dist/output.js.map +1 -0
- package/dist/pid.d.ts +5 -0
- package/dist/pid.d.ts.map +1 -0
- package/dist/pid.js +25 -0
- package/dist/pid.js.map +1 -0
- package/dist/quiet.d.ts +2 -0
- package/dist/quiet.d.ts.map +1 -0
- package/dist/quiet.js +12 -0
- package/dist/quiet.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ainize patch …` — publish, inspect, verify, buy and apply knowledge patches.
|
|
3
|
+
*/
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
6
|
+
import { basename, resolve } from 'node:path';
|
|
7
|
+
import { verificationCount } from '@ainize/core';
|
|
8
|
+
import { NodeClient, query } from '../client.js';
|
|
9
|
+
import { looksLikeEnsName, resolveName } from '../ens.js';
|
|
10
|
+
import * as auth from './auth.js';
|
|
11
|
+
import { CliError, PROG } from '../context.js';
|
|
12
|
+
import { ask, c, confirm, emit, emitStep, fmtBytes, fmtTime, info, kv, ok, shortAddr, shortHash, statusColor, table, warn } from '../output.js';
|
|
13
|
+
/** Position in the serving model's stack, by knowledge id (`GET /api/runtime/stack`) — item 216. */
|
|
14
|
+
export async function loadedPositions(client) {
|
|
15
|
+
const r = await client.get('/api/runtime/stack').catch(() => null);
|
|
16
|
+
return new Map((r?.stack ?? []).map((l, i) => [l.patch_id, i + 1]));
|
|
17
|
+
}
|
|
18
|
+
export async function patchLs(ctx, a = {}) {
|
|
19
|
+
const client = new NodeClient(ctx);
|
|
20
|
+
let items;
|
|
21
|
+
if (a.mine) {
|
|
22
|
+
items = (await client.get('/api/me/patches')).items;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const d = await client.get(`/api/catalog${query({ status: a.status, model: a.model, schema: a.schema, branch: a.branch, author: a.author, q: a.q, sort: a.sort ?? 'latest', limit: a.limit ?? 100, include_drafts: a.drafts })}`);
|
|
26
|
+
items = d.items;
|
|
27
|
+
}
|
|
28
|
+
const loaded = await loadedPositions(client);
|
|
29
|
+
// Item 114: `--name` is the field the publisher chooses and the web renders as the card title, and this listing —
|
|
30
|
+
// the one place a publisher tells their own drafts apart — did not have it. MODEL goes when every row shares one.
|
|
31
|
+
const oneModel = new Set(items.map((e) => e.anchor.model.id_M)).size <= 1;
|
|
32
|
+
const filtered = !!(a.status || a.model || a.schema || a.branch || a.author || a.q);
|
|
33
|
+
const empty = a.mine
|
|
34
|
+
? `you have not published anything on this node yet — \`${PROG} publish <file.npz> --name "<name>" --model <id_M> --benchmark <bench.json>\``
|
|
35
|
+
: filtered ? 'no knowledge matches those filters'
|
|
36
|
+
: `no knowledge on this node yet — publish one with \`${PROG} publish <file.npz> --name "<name>" --model <id_M> --benchmark <bench.json>\`, or add a peer that sells some (\`${PROG} peers add <node url>\`)`;
|
|
37
|
+
emit(ctx, items, (rows) => table(rows, [
|
|
38
|
+
{ key: 'id', title: 'ID', get: (e) => c.id(e.anchor.id) },
|
|
39
|
+
{ key: 'name', title: 'NAME', get: (e) => e.anchor.name },
|
|
40
|
+
{ key: 'status', title: 'STATUS', get: (e) => statusColor(e.status) },
|
|
41
|
+
// Item 216 — "what is loaded here, in what order" was answerable from no listing at all.
|
|
42
|
+
{ key: 'loaded', title: 'LOADED', get: (e) => (loaded.has(e.anchor.id) ? c.ok(`#${loaded.get(e.anchor.id)}`) : c.dim('-')), align: 'right' },
|
|
43
|
+
{ key: 'author', title: 'AUTHOR', get: (e) => (!e.anchor.author.startsWith('0x') ? e.anchor.author : e.anchor.author_name ? `${e.anchor.author_name} ${c.dim(shortAddr(e.anchor.author, 4))}` : shortAddr(e.anchor.author, 6)) },
|
|
44
|
+
...(oneModel ? [] : [{ key: 'model', title: 'MODEL', get: (e) => e.anchor.model.id_M }]),
|
|
45
|
+
{ key: 'rows', title: 'ROWS', get: (e) => e.anchor.rows.toLocaleString('en-US'), align: 'right' },
|
|
46
|
+
{ key: 'size', title: 'SIZE', get: (e) => fmtBytes(e.anchor.size_bytes), align: 'right' },
|
|
47
|
+
{ key: 'price', title: 'PRICE', get: (e) => `${e.anchor.price} ${e.anchor.currency}`, align: 'right' },
|
|
48
|
+
// The numerator never exceeds the quorum (`3/2` is not a fraction anyone can read); extra independent
|
|
49
|
+
// attestations are shown as `2/2+1`, and self-checks by the author are never in this count at all.
|
|
50
|
+
{ key: 'att', title: 'ATTEST', get: (e) => { const v = verificationCount(e); const s = v.extra ? `${v.fraction}+${v.extra}` : v.fraction; return e.quorum_ok ? c.ok(s) : c.warn(s); }, align: 'right' },
|
|
51
|
+
/**
|
|
52
|
+
* Item 201: SOLD was `settlements.length` — every e2e run's purchase and every self-buy included — and it was
|
|
53
|
+
* the only demand number anywhere. It is real sales now (someone else, a real price), with the last 30 days
|
|
54
|
+
* beside it, and DERIVED says how many published knowledges were built on this one.
|
|
55
|
+
*/
|
|
56
|
+
{ key: 'dl', title: 'SOLD', get: (e) => { const sold = e.sales?.sales_all ?? e.downloads; return sold && e.sales?.sales_30d ? `${sold} ${c.dim(`(${e.sales.sales_30d}/30d)`)}` : String(sold); }, align: 'right' },
|
|
57
|
+
{ key: 'derived', title: 'DERIVED', get: (e) => (e.built_on ? c.ok(String(e.built_on)) : c.dim('-')), align: 'right' },
|
|
58
|
+
/**
|
|
59
|
+
* Item 282 — the column that says whether the row is an add-on at all, which is the question a buyer comparing
|
|
60
|
+
* a 5-credit base with a 3-credit item built on it is actually asking. It used to be titled BUILT ON and hold
|
|
61
|
+
* item 201's derivative COUNT, so the base read "BUILT ON 1" and the derivative read "-": the exact opposite of
|
|
62
|
+
* what the words say, and of what the browse card next to it shows. Same two facts as the web chip, same order:
|
|
63
|
+
* `base.stack` is a body that does not work without what it names underneath; `parents` is one that does.
|
|
64
|
+
*/
|
|
65
|
+
{ key: 'bo', title: 'BUILT ON', get: (e) => builtOnCell(e) },
|
|
66
|
+
{ key: 'schema', title: 'BENCHMARK', get: (e) => e.anchor.benchmark.schema },
|
|
67
|
+
], empty) + (loaded.size ? '\n' + c.dim(`loaded in the serving model, in order: ${[...loaded.entries()].sort((x, y) => x[1] - y[1]).map(([id, n]) => `${n} ${id}`).join(' → ')} (${PROG} patch stack)`) : ''));
|
|
68
|
+
return items;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* What this knowledge is built on, in the words the browse card uses (item 282).
|
|
72
|
+
*
|
|
73
|
+
* `add-on: <ids>` — `base.stack`, a body trained against those tables: it does not work without them, and its price
|
|
74
|
+
* has to be read together with theirs. `<ids>` alone — declared `parents`: it stands on its own and shares revenue
|
|
75
|
+
* with them. `-` — a root. Ids are listed, not counted, because the one thing the reader needs is WHICH knowledge
|
|
76
|
+
* they also have to hold.
|
|
77
|
+
*/
|
|
78
|
+
export function builtOnCell(e) {
|
|
79
|
+
const stack = (e.anchor.base?.stack ?? []).map((b) => b.patch_id);
|
|
80
|
+
if (stack.length)
|
|
81
|
+
return c.warn(`add-on: ${stack.join(', ')}`);
|
|
82
|
+
const parents = e.anchor.parents ?? [];
|
|
83
|
+
if (parents.length)
|
|
84
|
+
return parents.join(', ');
|
|
85
|
+
return c.dim('-');
|
|
86
|
+
}
|
|
87
|
+
/** `2/2 passed ✓ quorum` — clamped, with the extra evidence spelled out instead of an unreadable `3/2`. */
|
|
88
|
+
export function verificationLine(e) {
|
|
89
|
+
const v = verificationCount(e);
|
|
90
|
+
const extra = v.extra ? c.dim(` (+${v.extra} more independent attestation${v.extra > 1 ? 's' : ''})`) : '';
|
|
91
|
+
const self = e.self_checks ? c.warn(` · ${e.self_checks} self-check${e.self_checks > 1 ? 's' : ''} by the author (not counted)`) : '';
|
|
92
|
+
return `${v.fraction} passed${e.quorum_ok ? c.ok(' ✓ quorum') : ''}${extra}${self}${executorLine(e)}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* How many distinct model servers are behind those attestations (item 329). Two verifier processes pointed at one
|
|
96
|
+
* vLLM sign two attestations and are not two independent verifications; the record can now tell the difference, so
|
|
97
|
+
* the line says which of the two it is instead of printing a fraction that means either.
|
|
98
|
+
*/
|
|
99
|
+
export function executorLine(e) {
|
|
100
|
+
const machines = (e.executors?.length ?? 0) + (e.executors_unknown ?? 0);
|
|
101
|
+
if (e.passed < 2 || !machines)
|
|
102
|
+
return '';
|
|
103
|
+
if ((e.executors?.length ?? 0) && machines < e.passed)
|
|
104
|
+
return c.warn(` · ${machines} model server${machines > 1 ? 's' : ''} for ${e.passed} attestations — not ${e.passed} independent runs`);
|
|
105
|
+
if (e.executors_unknown)
|
|
106
|
+
return c.dim(` · ${e.executors_unknown} attestation${e.executors_unknown > 1 ? 's' : ''} without an engine fingerprint`);
|
|
107
|
+
return c.dim(` · ${machines} independent model server${machines > 1 ? 's' : ''}`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* "3 CREDIT → node-b 2.1 (seller) · node-a 0.9 (creator of pixel-parent)" (item 280).
|
|
111
|
+
*
|
|
112
|
+
* The seller returns the whole split in `x-payment-response` on the same reply that hands over the manifest, and
|
|
113
|
+
* the buyer's receipt printed six timeline steps and a tx hash. A payee this node's own lineage view did not
|
|
114
|
+
* expect is marked, because that is the one thing the split is worth checking for.
|
|
115
|
+
*/
|
|
116
|
+
export function payeeLines(r, currency) {
|
|
117
|
+
const payees = r.payees ?? [];
|
|
118
|
+
if (!payees.length)
|
|
119
|
+
return [];
|
|
120
|
+
const who = (p) => `${p.name ?? shortAddr(p.address, 8)} ${c.bold(p.amount)} ${c.dim(`(${p.role}${p.knowledge.length ? ` of ${p.knowledge.join(', ')}` : ''})`)}`
|
|
121
|
+
+ (p.promised ? '' : c.warn(' — not in the lineage this node was shown'));
|
|
122
|
+
return [c.dim(` paid ${r.amount} ${currency} → `) + payees.map(who).join(c.dim(' · '))];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* "Each sale of 3 CREDIT → 2.1 to you, 0.9 shared by the creators of krx-all-2761" (item 189), and the one thing a
|
|
126
|
+
* publisher undercutting their own base is never told (item 318). Printed at publish, before the announce, because
|
|
127
|
+
* afterwards the price is on the permanent record.
|
|
128
|
+
*/
|
|
129
|
+
export function splitLines(s) {
|
|
130
|
+
if (!s.parents.length && s.lines.length <= 1)
|
|
131
|
+
return []; // nothing is shared: there is no split to explain
|
|
132
|
+
const money = (n) => `${n} ${s.currency}`;
|
|
133
|
+
const who = (l) => (l.role === 'seller' ? 'you'
|
|
134
|
+
: `${l.name ?? shortAddr(l.address, 6)}${l.knowledge.length ? ` (${l.knowledge.join(', ')})` : ''}`);
|
|
135
|
+
const out = [c.dim(` each sale of ${money(s.amount)} → `) + s.lines.map((l) => `${c.id(money(l.amount))} ${c.dim(`to ${who(l)}`)}`).join(c.dim(' · '))];
|
|
136
|
+
for (const p of s.parents) {
|
|
137
|
+
out.push(c.dim(` base ${p.id}${p.price !== null ? ` sells at ${p.price} ${p.currency}` : ' (price unknown here)'}${p.author_name ? ` from ${p.author_name}` : ''}${p.status ? ` · ${p.status}` : ''}`));
|
|
138
|
+
}
|
|
139
|
+
for (const u of s.cheaper_than) {
|
|
140
|
+
out.push(c.warn(` ! ${money(s.amount)} is below ${u.id}'s own price of ${u.price} ${u.currency} — buyers get its rows for less from you, and its creator's take drops from ${u.price} to a royalty slice.`));
|
|
141
|
+
}
|
|
142
|
+
const held = Object.keys(s.unresolved);
|
|
143
|
+
if (held.length)
|
|
144
|
+
out.push(c.warn(` ! ${held.join(', ')}: no anchor here names an author, so their share is held, not paid — add the node that publishes ${held.length > 1 ? 'them' : 'it'} (${PROG} peers add <url>).`));
|
|
145
|
+
// Item 322: the rule that decides the split, in one sentence — it lived only in a source comment, so a creator
|
|
146
|
+
// choosing between one base and two made a permanent revenue decision with nothing in front of them.
|
|
147
|
+
if (s.rule)
|
|
148
|
+
out.push(c.dim(` how it is decided: ${s.rule}`));
|
|
149
|
+
// Item 366: what a sale costs to settle here, from gas this node has actually paid — never an estimate.
|
|
150
|
+
if (s.cost && Number(s.amount) > 0 && Number(s.amount) < Number(s.cost.floor)) {
|
|
151
|
+
out.push(c.warn(` ! ${s.amount} ${s.currency} is below what a sale of it costs to settle on this chain: ${s.cost.writes} write(s) at ${s.cost.gas_avg} ${s.currency} of gas each (measured over ${s.cost.measured_writes} of this node's own writes) = ${s.cost.floor}. Every sale would lose money.`));
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Holding a file is not owning the knowledge (items 173, 343).
|
|
157
|
+
*
|
|
158
|
+
* A verifier fetches every body it scores, so `body on this node: yes` was true on knowledge nobody had bought and
|
|
159
|
+
* nobody was licensed to use, teach on, or subscribe with — and `patch buy` then charged for it and reported
|
|
160
|
+
* "body already present", which reads as "nothing happened". The node has known the difference all along
|
|
161
|
+
* (`licenseOf(e).source === 'verification'`); this is that fact, in the buyer's words.
|
|
162
|
+
*/
|
|
163
|
+
export function licenceLine(e, tx) {
|
|
164
|
+
if (e.owned)
|
|
165
|
+
return c.ok('yours — you published it');
|
|
166
|
+
if (e.purchased)
|
|
167
|
+
return c.ok(`bought by this node${tx ? ` · tx ${shortHash(tx, 14)}` : ''}`);
|
|
168
|
+
if (e.has_body)
|
|
169
|
+
return c.warn('NOT bought — the file is here because this node verified it, and verifying is not a licence to use, teach on or subscribe with');
|
|
170
|
+
return 'not bought';
|
|
171
|
+
}
|
|
172
|
+
/** Names for the addresses a settlement pays, so the ROYALTY column is not a column of hex (item 198). */
|
|
173
|
+
async function royaltyNames(client, d) {
|
|
174
|
+
const names = new Map();
|
|
175
|
+
const put = (addr, name) => { if (addr && name)
|
|
176
|
+
names.set(addr.toLowerCase(), name); };
|
|
177
|
+
put(d.anchor.author, d.anchor.author_name);
|
|
178
|
+
for (const cn of d.anchor.contributors ?? [])
|
|
179
|
+
put(cn.address, cn.name ?? null);
|
|
180
|
+
for (const r of d.requires ?? [])
|
|
181
|
+
put(r.author, r.author_name ?? null);
|
|
182
|
+
const owed = new Set(d.settlements.flatMap((s) => Object.keys(s.royalty)).map((x) => x.toLowerCase()));
|
|
183
|
+
if ([...owed].every((x) => names.has(x)))
|
|
184
|
+
return names;
|
|
185
|
+
// The tree endpoint resolves every recipient of the family's split, lineage and contributors alike.
|
|
186
|
+
const tree = await client.get(`/api/patches/${encodeURIComponent(d.anchor.id)}/tree${query({ depth: 4 })}`).catch(() => null);
|
|
187
|
+
for (const r of tree?.money.recipients ?? [])
|
|
188
|
+
put(r.address, r.name);
|
|
189
|
+
return names;
|
|
190
|
+
}
|
|
191
|
+
export async function patchGet(ctx, id) {
|
|
192
|
+
const client = new NodeClient(ctx);
|
|
193
|
+
const d = await client.get(`/api/patches/${encodeURIComponent(id)}`);
|
|
194
|
+
// The receipt for a knowledge this node bought (operator-only; a visitor simply gets no tx hash).
|
|
195
|
+
const tx = d.purchased && !d.owned
|
|
196
|
+
? (await client.get('/api/me/purchases').catch(() => null))?.items.find((p) => p.patch_id === d.anchor.id)?.tx_hash ?? null
|
|
197
|
+
: null;
|
|
198
|
+
const names = d.settlements.length ? await royaltyNames(client, d) : new Map();
|
|
199
|
+
const who = (addr) => names.get(addr.toLowerCase()) ?? shortAddr(addr, 4);
|
|
200
|
+
emit(ctx, d, (e) => {
|
|
201
|
+
const a = e.anchor;
|
|
202
|
+
const lines = [
|
|
203
|
+
c.bold(a.name) + ' ' + statusColor(e.status) + (e.owned ? c.dim(' (yours)') : '') + (e.purchased ? c.ok(' purchased') : '') + (e.applied ? c.ok(' applied') : ''),
|
|
204
|
+
kv([
|
|
205
|
+
['id', a.id], ['author', `${a.author_name ?? ''} ${a.author}`.trim()], ['model', `${a.model.id_M}${a.model.checkpoint_hash ? ` (${a.model.checkpoint_hash})` : ''}`],
|
|
206
|
+
// Item 198 — a lesson's data provider was on the record and on the web page, and in no terminal output.
|
|
207
|
+
...(a.contributors?.length ? [['taught by', a.contributors.map((x) => `${x.name ?? shortAddr(x.address, 4)} (${Math.round(x.share * 100)}% of this node's share of each sale${x.proof === 'signed' ? ', signed' : ''})`).join(', ')]] : []),
|
|
208
|
+
['rows / size', `${a.rows.toLocaleString('en-US')} rows · ${fmtBytes(a.size_bytes)}`], ['sha256', a.patch_sha256],
|
|
209
|
+
['price', `${a.price} ${a.currency} · ${a.billing}`], ['benchmark', `${a.benchmark.schema} · ${a.benchmark.queries} queries · ${a.benchmark.format.join('/')}${a.benchmark.collateral_bound_nat ? ` · collateral ≤ ${a.benchmark.collateral_bound_nat} nat` : ''}`],
|
|
210
|
+
['benchmark hash', a.benchmark_hash], ['topic', a.topic_path], ['branch', a.branch ?? '-'], ['gateway', e.gateway_url ?? '-'],
|
|
211
|
+
// Item 194: `revenue` is the price buyers paid, which on a derivative is up to three times what its author
|
|
212
|
+
// received. Both numbers, and the one the author actually kept named as such.
|
|
213
|
+
['verification', verificationLine(e)], ['sold', `${e.downloads}${e.buyers && e.buyers !== e.downloads ? ` (${e.buyers} buyer${e.buyers === 1 ? '' : 's'})` : ''} · revenue ${e.revenue} ${a.currency} gross`
|
|
214
|
+
+ (Number(e.revenue_shared ?? 0) > 0 ? ` · ${e.revenue_net} to ${a.author_name ?? shortAddr(a.author, 6)}, ${e.revenue_shared} shared with the creators it was built on` : '')
|
|
215
|
+
+ (e.self_purchases ? c.warn(` · ${e.self_purchases} self-purchase${e.self_purchases === 1 ? '' : 's'} not counted`) : '')],
|
|
216
|
+
// Item 267 — the day the DATA is true of, beside the day it was registered. On a daily bake these are
|
|
217
|
+
// different questions and only one of them tells a consumer whether the file is stale.
|
|
218
|
+
...(a.as_of ? [['data as of', `${a.as_of} ${c.dim('(the day the data is true of)')}`]] : []),
|
|
219
|
+
['created', fmtTime(a.created_at)],
|
|
220
|
+
// Items 173 / 343: what this node may DO with it, before the line that says whether the file is here.
|
|
221
|
+
['licence', licenceLine(e, tx)], ['body on this node', e.has_body ? 'yes' : 'no'],
|
|
222
|
+
]),
|
|
223
|
+
'', a.description ? a.description : c.dim('(no description)'),
|
|
224
|
+
];
|
|
225
|
+
/**
|
|
226
|
+
* Item 254 — the minutes before the FIRST attestation, when the status is still ANNOUNCED and every screen
|
|
227
|
+
* said "awaiting verification". Who has not answered yet, how many of them can run this model, and how long
|
|
228
|
+
* verification has actually taken on this node before (a median of what happened, never an invented ETA).
|
|
229
|
+
*/
|
|
230
|
+
if (e.verifying && !e.stalled) {
|
|
231
|
+
const v = e.verifying;
|
|
232
|
+
const mins = (ms) => `${Math.max(1, Math.round(ms / 60_000))} min`;
|
|
233
|
+
lines.push('', c.warn(`verifying — ${v.counted}/${v.quorum} so far, ${mins(v.waited_ms)} since it was announced`));
|
|
234
|
+
lines.push(v.waiting_on.length
|
|
235
|
+
? c.dim(` waiting on ${v.waiting_on.length} verifier(s): ${v.waiting_on.map((w) => `${w.name}${w.can_run ? '' : c.warn(` (serves ${w.model ?? 'no model'} — cannot run this one)`)}`).join(', ')}`)
|
|
236
|
+
: c.warn(' no verifier node answers this one right now — nothing announced here can be listed until one does (`ainize peers add <url>`)'));
|
|
237
|
+
if (v.typical_ms !== null)
|
|
238
|
+
lines.push(c.dim(` this node's own knowledge has taken about ${mins(v.typical_ms)} from announce to verified${v.eta_ms !== null && v.eta_ms > 0 ? ` — roughly ${mins(v.eta_ms)} left on that pace` : ' — this one is already past that'}`));
|
|
239
|
+
}
|
|
240
|
+
// Item 154: an anchor stuck at 0/2 used to say nothing at all on the author's own machine — the retry warnings
|
|
241
|
+
// are events on the VERIFIERS' nodes. Print what this node knows, and who it asked.
|
|
242
|
+
if (e.stalled) {
|
|
243
|
+
lines.push('', c.warn(`not verified after ${e.stalled.waited_minutes} minutes — ${e.stalled.counted}/${e.stalled.quorum}`), ` ${e.stalled.reason}`);
|
|
244
|
+
if (e.stalled.verifiers.length) {
|
|
245
|
+
lines.push(...e.stalled.verifiers.map((v) => c.dim(` ${v.name ?? v.endpoint} — serves ${v.model ?? 'no model'}; attested: ${v.attested}`)));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (e.attestations.length) {
|
|
249
|
+
lines.push('', c.head('attestations'), table(e.attestations, [
|
|
250
|
+
{ key: 'v', title: 'VERIFIER', get: (x) => `${x.verifier_name ?? ''} ${c.dim(shortAddr(x.verifier, 6))}`.trim() },
|
|
251
|
+
{ key: 'r', title: 'RESULT', get: (x) => (x.passed ? c.ok('PASS') : c.err('FAIL')) },
|
|
252
|
+
{ key: 's', title: 'SCORE', get: (x) => Object.entries(x.score).map(([k, v]) => `${k}=${v}`).join(' ') },
|
|
253
|
+
{ key: 'on', title: 'VERIFIED ON', get: (x) => x.verified_on },
|
|
254
|
+
{ key: 'rs', title: 'RESTARTS', get: (x) => String(x.restarts_detected ?? 0), align: 'right' },
|
|
255
|
+
// `self_checks` is how many of the author's own attestations this node excluded (0 on a node that counts them).
|
|
256
|
+
{ key: 'ct', title: 'COUNTS', get: (x) => (e.self_checks > 0 && x.verifier.toLowerCase() === a.author.toLowerCase() ? c.warn('no — self-check') : 'yes') },
|
|
257
|
+
{ key: 't', title: 'AT', get: (x) => fmtTime(x.created_at) },
|
|
258
|
+
]));
|
|
259
|
+
// A FAIL used to be a fraction and nothing else. The verifier signs what the model actually answered on up to
|
|
260
|
+
// five of the questions it got wrong (item 155) — print it under the row, because that is what a rejected
|
|
261
|
+
// author has to work from.
|
|
262
|
+
for (const x of e.attestations) {
|
|
263
|
+
if (!x.failures?.length)
|
|
264
|
+
continue;
|
|
265
|
+
lines.push('', `${c.err('FAIL')} ${x.verifier_name ?? shortAddr(x.verifier, 6)} — what the model answered on ${x.failures.length} of the questions it got wrong:`);
|
|
266
|
+
for (const f of x.failures)
|
|
267
|
+
lines.push(` ${c.dim('asked ')} ${f.prompt}`, ` ${c.dim('expected')} ${c.ok(f.expect)}`, ` ${c.dim('answered')} ${c.err(f.got || '(nothing)')}`, '');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (e.challenges.length) {
|
|
271
|
+
lines.push('', c.head('challenges'), table([...e.challenges].sort((x, y) => y.created_at - x.created_at), [
|
|
272
|
+
{ key: 'c', title: 'CHALLENGER', get: (x) => shortAddr(x.challenger, 8) },
|
|
273
|
+
{ key: 'r', title: 'REASON', get: (x) => x.reason },
|
|
274
|
+
{ key: 'o', title: 'OPEN', get: (x) => (e.open_challenge && x.created_at === e.open_challenge.created_at && x.challenger === e.open_challenge.challenger ? c.warn('yes — sale on hold') : c.dim('answered')) },
|
|
275
|
+
{ key: 't', title: 'AT', get: (x) => fmtTime(x.created_at) },
|
|
276
|
+
]));
|
|
277
|
+
}
|
|
278
|
+
lines.push('', c.head('lineage'), ` parents : ${e.lineage.parents.map((p) => `${p.id} ${c.dim(`(${p.status}${p.price !== undefined ? `, ${p.price} ${p.currency ?? e.anchor.currency}` : ''})`)}`).join(', ') || c.dim('none (root)')}`,
|
|
279
|
+
// Items 195, 318: a child was an id and a status. Its PRICE — a child under the base's own price is the base
|
|
280
|
+
// at a discount — and what it has actually paid the creator of the base are what an ancestor needs to see.
|
|
281
|
+
` children: ${e.lineage.children.map((p) => `${p.id} ${c.dim(`(${p.status}${p.price !== undefined ? `, ${p.price} ${p.currency ?? e.anchor.currency}` : ''}${p.author_name && p.author !== e.anchor.author ? `, ${p.author_name}` : ''})`)}${p.sales ? c.ok(` +${p.earned} from ${p.sales} sale${p.sales > 1 ? 's' : ''}`) : ''}`).join(', ') || c.dim('none')}`, e.lineage.earned ? ` earned from derivatives: ${e.lineage.earned.sales ? `${e.lineage.earned.amount} ${e.lineage.earned.currency} from ${e.lineage.earned.sales} sale(s) of knowledge built on this` : c.dim('nothing yet — it appears here as derivatives sell')}` : '', e.supersedes.length ? ` supersedes: ${e.supersedes.join(', ')}` : '', e.superseded_by.length ? c.warn(` superseded by: ${e.superseded_by.join(', ')}`) : '');
|
|
282
|
+
if (e.conflicts.length) {
|
|
283
|
+
lines.push('', c.head('address-set overlaps (A₁ ∩ A₂)'), table(e.conflicts, [
|
|
284
|
+
{ key: 'p', title: 'PATCH', get: (x) => x.patch_id }, { key: 'o', title: 'SHARED ROWS', get: (x) => x.overlap_rows.toLocaleString('en-US'), align: 'right' },
|
|
285
|
+
{ key: 's', title: 'SAME SCHEMA', get: (x) => (x.lineage ? c.ok(`yes → its ${x.lineage === 'parent' ? 'base' : 'add-on'}, not a rival`) : x.same_schema ? c.warn('yes → conflicting knowledge') : 'no') }, { key: 'st', title: 'STATUS', get: (x) => statusColor(x.status) },
|
|
286
|
+
]));
|
|
287
|
+
}
|
|
288
|
+
if (e.branches.length)
|
|
289
|
+
lines.push('', c.head('branches'), ...e.branches.map((b) => ` ${b.name} ${c.dim(JSON.stringify(b.context))}`));
|
|
290
|
+
if (e.settlements.length) {
|
|
291
|
+
lines.push('', c.head('settlements'), table(e.settlements.slice(-10), [
|
|
292
|
+
{ key: 'b', title: 'BUYER', get: (s) => shortAddr(s.buyer, 8) }, { key: 'a', title: 'AMOUNT', get: (s) => `${s.amount} ${s.currency}`, align: 'right' },
|
|
293
|
+
{ key: 'sch', title: 'SCHEME', get: (s) => s.scheme }, { key: 'tx', title: 'TX', get: (s) => shortHash(s.tx_hash, 14) },
|
|
294
|
+
// Item 198 — `0x1111…1111:0.75` named nobody. The recipients are resolved from the record itself.
|
|
295
|
+
{ key: 'r', title: 'ROYALTY', get: (s) => Object.entries(s.royalty).map(([k, v]) => `${who(k)}:${v}`).join(' ') }, { key: 't', title: 'AT', get: (s) => fmtTime(s.created_at) },
|
|
296
|
+
]));
|
|
297
|
+
}
|
|
298
|
+
return lines.filter((l) => l !== '').join('\n');
|
|
299
|
+
});
|
|
300
|
+
return d;
|
|
301
|
+
}
|
|
302
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
303
|
+
/**
|
|
304
|
+
* `--contributor 0xabc…:Alice:0.7` → Contributor (role data_provider, proof declared — the operator vouches for the address;
|
|
305
|
+
* a *signed* proof only comes from the browser teach flow). `0xabc…:0.7` omits the name; `0xabc…:Alice:0` is credit-only.
|
|
306
|
+
*/
|
|
307
|
+
export function parseContributors(list) {
|
|
308
|
+
if (!list?.length)
|
|
309
|
+
return undefined;
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const raw of list) {
|
|
312
|
+
const parts = raw.split(':').map((x) => x.trim());
|
|
313
|
+
if (parts.length < 2)
|
|
314
|
+
throw new CliError(`--contributor must be addr:name:share or addr:share (got "${raw}")`);
|
|
315
|
+
const address = parts[0];
|
|
316
|
+
const shareStr = parts[parts.length - 1];
|
|
317
|
+
const name = parts.slice(1, -1).join(':').trim();
|
|
318
|
+
if (!ADDR_RE.test(address))
|
|
319
|
+
throw new CliError(`--contributor: "${address}" is not an AIN address (0x + 40 hex)`);
|
|
320
|
+
const share = Number(shareStr.endsWith('%') ? Number(shareStr.slice(0, -1)) / 100 : shareStr);
|
|
321
|
+
if (!Number.isFinite(share) || share < 0 || share > 1)
|
|
322
|
+
throw new CliError(`--contributor: share must be 0..1 (or 0%..100%), got "${shareStr}"`);
|
|
323
|
+
if (name.length > 40)
|
|
324
|
+
throw new CliError('--contributor: name must be at most 40 characters');
|
|
325
|
+
out.push({ address, share, role: 'data_provider', proof: 'declared', ...(name ? { name } : {}) });
|
|
326
|
+
}
|
|
327
|
+
if (out.length > 4)
|
|
328
|
+
throw new CliError('at most 4 contributors per patch');
|
|
329
|
+
const sum = out.reduce((a, x) => a + x.share, 0);
|
|
330
|
+
if (sum > 1 + 1e-9)
|
|
331
|
+
throw new CliError(`contributor shares add up to ${sum} (> 1)`);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/** The node's own id rule (`market.createDraft`), mirrored so the CLI can say what will happen before it happens. */
|
|
335
|
+
export const slugForId = (name) => name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
336
|
+
/** …and the shape the node then demands of it. */
|
|
337
|
+
const NODE_SLUG = /^[a-z0-9][a-z0-9._-]{1,63}$/;
|
|
338
|
+
/**
|
|
339
|
+
* The id of a knowledge whose publisher did not choose one (item 158).
|
|
340
|
+
*
|
|
341
|
+
* The node derives it from the name by dropping everything outside `a-z 0-9 . _ -`, so every Korean, Japanese or
|
|
342
|
+
* Chinese name collapsed to the empty string and the publisher was told `invalid patch id (use 2-64 chars …)` —
|
|
343
|
+
* a rule about a flag they never passed, on the product's own flagship subject. A name with no usable ASCII gets a
|
|
344
|
+
* derived, stable id here instead, and the command says so; `--id` still wins over everything.
|
|
345
|
+
*/
|
|
346
|
+
export function idFromName(name) {
|
|
347
|
+
const slug = slugForId(name);
|
|
348
|
+
if (NODE_SLUG.test(slug))
|
|
349
|
+
return { id: slug, from: 'name' };
|
|
350
|
+
return { id: `patch-${createHash('sha256').update(name).digest('hex').slice(0, 8)}`, from: 'hash' };
|
|
351
|
+
}
|
|
352
|
+
/** The benchmark, from a path or from inline JSON — with the three mistakes told apart (item 159). */
|
|
353
|
+
export function readBenchmarkArg(value) {
|
|
354
|
+
const raw = (value ?? '').trim();
|
|
355
|
+
if (raw.startsWith('{')) {
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(raw);
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
throw new CliError(`--benchmark was read as inline JSON (it starts with "{") and could not be parsed: ${e.message}\n`
|
|
361
|
+
+ ` it must be an object like {"schema":"krx-ticker-codes","queries":2761,"format":["template"],"samples":[{"prompt":"…","expect":"…"}]}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const p = resolve(raw);
|
|
365
|
+
if (!existsSync(p))
|
|
366
|
+
throw new CliError(`benchmark file not found: ${p}\n --benchmark takes a path to a JSON file, or inline JSON starting with "{"`);
|
|
367
|
+
try {
|
|
368
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
throw new CliError(`${p} is not valid JSON: ${e.message}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A knowledge body is a numpy archive, which is a ZIP (item 159). Publishing something else used to reach the npz
|
|
376
|
+
* reader on the node and come back as `npz: end of central directory not found` — a ZIP structure term, with no
|
|
377
|
+
* filename, that gives no hint the file is simply the wrong kind.
|
|
378
|
+
*/
|
|
379
|
+
export function assertNpzBody(file) {
|
|
380
|
+
const head = Buffer.alloc(4);
|
|
381
|
+
const fd = openSync(file, 'r');
|
|
382
|
+
let n = 0;
|
|
383
|
+
try {
|
|
384
|
+
n = readSync(fd, head, 0, 4, 0);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
closeSync(fd);
|
|
388
|
+
}
|
|
389
|
+
if (n === 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04)
|
|
390
|
+
return;
|
|
391
|
+
throw new CliError(`${file} is not a valid .npz — a knowledge body is a numpy archive (a ZIP holding the addrs / before / after arrays), and this file does not start with one.\n`
|
|
392
|
+
+ ` ${statSync(file).size} bytes, first bytes ${[...head.subarray(0, n)].map((x) => x.toString(16).padStart(2, '0')).join(' ') || '(empty file)'}.\n`
|
|
393
|
+
+ ` Export it again from the trainer, or pass the file the lesson produced (\`lesson-*.npz\`).`);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* `--branch <name>` used to file into nothing (item 169c).
|
|
397
|
+
*
|
|
398
|
+
* `anchor.branch` is a free-text label on the record. Every track surface — `branch ls`, `patch ls --branch`,
|
|
399
|
+
* `/api/route`, the /network table, `branch subscribe` — reads `BranchInfo.patch_ids` instead, and nothing ever
|
|
400
|
+
* joins the two. So `ainize publish --branch law/KR` was accepted in full, printed nothing, and left `branch ls`
|
|
401
|
+
* saying "no branches yet": the publisher believed their knowledge was on a track that did not exist.
|
|
402
|
+
*
|
|
403
|
+
* Publishing is not the place to create a public track as a side effect, and `branch add` refuses knowledge that is
|
|
404
|
+
* not verified yet, so this cannot silently do the filing either. It says exactly where the label stands instead:
|
|
405
|
+
* an unknown track is refused before the draft is written, and a real one is recorded with the one command that
|
|
406
|
+
* finishes the job once the network has verified it.
|
|
407
|
+
*/
|
|
408
|
+
async function checkBranch(ctx, client, name) {
|
|
409
|
+
// `mine` on this response is the tracks this node SUBSCRIBES to, not the ones it owns — the question here is who
|
|
410
|
+
// may put knowledge on it, which is the owner.
|
|
411
|
+
const [d, info] = await Promise.all([
|
|
412
|
+
client.get('/api/branches?include_test=1&include_archived=1'),
|
|
413
|
+
client.get('/api/info'),
|
|
414
|
+
]);
|
|
415
|
+
const b = d.branches.find((x) => x.name === name);
|
|
416
|
+
if (!b) {
|
|
417
|
+
const near = d.branches.map((x) => x.name).filter((n) => n.split('/')[0] === name.split('/')[0]);
|
|
418
|
+
throw new CliError(`no knowledge track called ${JSON.stringify(name)} on this node, and --branch does not create one: the name would go on the permanent record and `
|
|
419
|
+
+ `\`${PROG} branch ls\`, \`${PROG} route\` and every subscriber would still see nothing.\n`
|
|
420
|
+
+ ` create it first: ${PROG} branch create ${name} --context <key>=<value>\n`
|
|
421
|
+
+ (near.length ? ` or did you mean: ${near.join(', ')}\n` : '')
|
|
422
|
+
+ ` or publish without --branch — knowledge does not need a track.`);
|
|
423
|
+
}
|
|
424
|
+
const mine = b.owner.toLowerCase() === info.node.address.toLowerCase();
|
|
425
|
+
if (!mine)
|
|
426
|
+
warn(ctx, `${name} belongs to ${shortAddr(b.owner, 6)}, not this node. The name goes on your record, but only that track's owner can put knowledge on it (\`${PROG} branch add\`), so it will not appear there until they do.`);
|
|
427
|
+
return { mine };
|
|
428
|
+
}
|
|
429
|
+
export async function patchPublish(ctx, a) {
|
|
430
|
+
const file = resolve(a.file);
|
|
431
|
+
if (!existsSync(file))
|
|
432
|
+
throw new CliError(`file not found: ${file}`);
|
|
433
|
+
if (!file.endsWith('.npz'))
|
|
434
|
+
throw new CliError('patch body must be a .npz (addrs/before/after arrays)');
|
|
435
|
+
assertNpzBody(file);
|
|
436
|
+
const benchmark = readBenchmarkArg(a.benchmark);
|
|
437
|
+
const b = benchmark;
|
|
438
|
+
if (!b.schema)
|
|
439
|
+
throw new CliError('benchmark.schema is required (e.g. "krx-ticker-codes")');
|
|
440
|
+
if (!b.queries)
|
|
441
|
+
b.queries = 0;
|
|
442
|
+
if (!b.format)
|
|
443
|
+
b.format = ['template'];
|
|
444
|
+
const contributors = parseContributors(a.contributor);
|
|
445
|
+
const client = new NodeClient(ctx);
|
|
446
|
+
const datasetFile = a.dataset ? resolve(a.dataset) : undefined;
|
|
447
|
+
if (datasetFile && !existsSync(datasetFile))
|
|
448
|
+
throw new CliError(`training set not found: ${datasetFile}`);
|
|
449
|
+
// Item 158 — an id derived from a name that has no ASCII at all.
|
|
450
|
+
// …and before anything is written, that --branch names a track that exists (item 169c).
|
|
451
|
+
const branch = a.branch ? await checkBranch(ctx, client, a.branch) : null;
|
|
452
|
+
const derived = a.id ? { id: a.id, from: 'name' } : idFromName(a.name);
|
|
453
|
+
if (derived.from === 'hash') {
|
|
454
|
+
warn(ctx, `no id could be made from the name ${JSON.stringify(a.name)} — an id is a-z 0-9 . _ - and that name has none of them, so this draft is ${c.id(derived.id)}.`);
|
|
455
|
+
info(ctx, c.dim(` choose your own instead: ${PROG} patch rm ${derived.id} && ${PROG} publish ${a.file} --id <your-slug> --name ${JSON.stringify(a.name)} …`));
|
|
456
|
+
}
|
|
457
|
+
const r = await client.post('/api/patches', {
|
|
458
|
+
id: derived.id, name: a.name, model_id: a.model, benchmark: JSON.stringify(b), price: a.price, description: a.description, parents: a.parents,
|
|
459
|
+
branch: a.branch, topic_path: a.topic, license: a.license, billing: a.billing, path: file, visibility: a.test ? 'test' : undefined,
|
|
460
|
+
...(a.asOf ? { as_of: a.asOf } : {}),
|
|
461
|
+
// Item 188: what this is to its base. The node measures added / changed rows from the two bodies.
|
|
462
|
+
...(a.kind ? { kind: a.kind } : {}),
|
|
463
|
+
contributors: contributors ? JSON.stringify(contributors) : undefined,
|
|
464
|
+
// past `duplicate_body` / `model_mismatch` only — the node never lets --force publish another author's bytes
|
|
465
|
+
...(a.force ? { force: true } : {}),
|
|
466
|
+
// the questions this knowledge was made from, published under the access level the operator chose (§6.1)
|
|
467
|
+
...(datasetFile ? { dataset_file: datasetFile, dataset_access: a.datasetAccess ?? 'derivative', dataset_license: a.datasetLicense } : {}),
|
|
468
|
+
});
|
|
469
|
+
ok(ctx, `draft created: ${c.id(r.anchor.id)} (${r.anchor.rows.toLocaleString('en-US')} rows, sha256 ${shortHash(r.anchor.patch_sha256)})`);
|
|
470
|
+
// Item 188: what the record now says this knowledge is to its base, in the numbers the node measured.
|
|
471
|
+
const der = r.anchor.derivation;
|
|
472
|
+
if (der) {
|
|
473
|
+
ok(ctx, c.dim(`on the record: this ${der.kind}s ${der.bases.map((x) => x.patch_id).join(', ')} — ${der.added_rows.toLocaleString('en-US')} new memory entries, ${der.changed_rows.toLocaleString('en-US')} written over theirs`));
|
|
474
|
+
}
|
|
475
|
+
if (r.anchor.dataset)
|
|
476
|
+
ok(ctx, c.dim(`training set on the record: ${r.anchor.dataset.rows} questions, ${r.anchor.dataset.access ?? 'private'}${r.anchor.dataset.license ? `, ${r.anchor.dataset.license}` : ''} (sha256 ${shortHash(r.anchor.dataset.sha256)})`));
|
|
477
|
+
if (r.anchor.contributors?.length)
|
|
478
|
+
ok(ctx, c.dim(`data providers on the record: ${r.anchor.contributors.map((x) => `${x.name ?? shortAddr(x.address, 4)} ${Math.round(x.share * 100)}%`).join(', ')} (of this node's share of each sale)`));
|
|
479
|
+
// Items 189 + 318: the money split and the parents' prices, while the draft is still a draft and the price can change.
|
|
480
|
+
// Item 322: also when there are no parents but there ARE data providers — a lesson that pays its teacher has a
|
|
481
|
+
// split to show, and the rule that decides it is printed with it.
|
|
482
|
+
if (!ctx.json && (r.anchor.parents?.length || r.anchor.contributors?.length)) {
|
|
483
|
+
const detail = await client.get(`/api/patches/${encodeURIComponent(r.anchor.id)}`).catch(() => null);
|
|
484
|
+
const lines = detail?.split ? splitLines(detail.split) : [];
|
|
485
|
+
if (lines.length)
|
|
486
|
+
process.stderr.write(lines.join('\n') + '\n' + c.dim(` the price goes on the permanent record at announce; until then \`${PROG} patch rm ${r.anchor.id}\` and publish again changes it.\n`));
|
|
487
|
+
}
|
|
488
|
+
let announced = null;
|
|
489
|
+
if (a.announce)
|
|
490
|
+
announced = await patchAnnounce(ctx, r.anchor.id, { supersede: a.supersede, keepOthers: a.keepOthers, document: false });
|
|
491
|
+
else if (!ctx.json)
|
|
492
|
+
ok(ctx, c.dim(`announce when ready: ${PROG} patch announce ${r.anchor.id}`));
|
|
493
|
+
// Item 169c — the last half of what --branch really did. The label is on the record now; a track carries
|
|
494
|
+
// patch_ids, and `branch add` takes only verified knowledge, so this is the step that is genuinely still to come
|
|
495
|
+
// rather than something publish could have done here.
|
|
496
|
+
if (branch?.mine && !ctx.json) {
|
|
497
|
+
ok(ctx, c.dim(`${a.branch} is recorded on this knowledge. It joins the track — and reaches its subscribers and \`${PROG} route\` — once the network has verified it: ${PROG} branch add ${a.branch} ${r.anchor.id}`));
|
|
498
|
+
}
|
|
499
|
+
// Item 253: the morning script had `{anchor, announced}` and a 64-integer addr_sketch, and had to poll
|
|
500
|
+
// `patch get --json` to learn whether the announce landed, what it retired and whether anyone can verify it.
|
|
501
|
+
emit(ctx, publishDocument(r.anchor, announced), () => undefined);
|
|
502
|
+
return { anchor: r.anchor, announced: !!announced };
|
|
503
|
+
}
|
|
504
|
+
/** `ainize --json publish` — the record hash, the derived status and what the announce retired, without the sketch. */
|
|
505
|
+
export function publishDocument(anchor, announced) {
|
|
506
|
+
const { addr_sketch: _sketch, ...rest } = anchor; // 64 integers a script has no use for; the sha256 identifies the body
|
|
507
|
+
return {
|
|
508
|
+
anchor: rest,
|
|
509
|
+
announced: !!announced,
|
|
510
|
+
status: announced ? 'ANNOUNCED' : 'DRAFT',
|
|
511
|
+
record_hash: announced?.record.hash ?? null,
|
|
512
|
+
pending_supersedes: (announced?.retires ?? []).map((x) => ({ id: x.patch_id, status: x.status, sales: x.sales ?? 0, overlap_rows: x.overlap_rows ?? 0 })),
|
|
513
|
+
verifiers_known: announced?.verifiers?.verifiers ?? null,
|
|
514
|
+
quorum: announced?.verifiers?.quorum ?? null,
|
|
515
|
+
visibility: announced?.visibility ?? anchor.visibility ?? 'public',
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The overlaps an announce would RETIRE: same subject, same branch, still tradeable, and published by this same node
|
|
520
|
+
* — the node's own rule (market.supersedable). A cross-author overlap is not in this list any more: it coexists.
|
|
521
|
+
*/
|
|
522
|
+
export function retiredByAnnounce(d) {
|
|
523
|
+
// A declared base is not retired by its own add-on (item 189) — unless this draft says it is a new VERSION of it
|
|
524
|
+
// (`publish --kind update`, item 188), which is the one declaration that turns a family link into a supersede.
|
|
525
|
+
const updates = new Set(d.anchor.derivation?.kind === 'update' ? (d.anchor.derivation.bases ?? []).map((b) => b.patch_id) : []);
|
|
526
|
+
return d.conflicts.filter((x) => x.same_schema && !x.cross_branch && x.same_author !== false && (!x.lineage || updates.has(x.patch_id))
|
|
527
|
+
&& ['LISTED', 'VERIFYING', 'ANNOUNCED'].includes(x.status));
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* DRAFT → ANNOUNCED, with the two things the publisher was never told (items 147, 150):
|
|
531
|
+
* - what this announce retires, listed with each item's status and sales, and refused until every one of them is
|
|
532
|
+
* named with `--supersede` — the same friction the console demands before the same, permanent, irreversible write;
|
|
533
|
+
* - whether any reachable peer on this network actually verifies. Below the quorum nothing announced here can ever
|
|
534
|
+
* be LISTED, so the old unconditional "verifiers will now attest" was a promise the node could not keep.
|
|
535
|
+
*/
|
|
536
|
+
export async function patchAnnounce(ctx, id, opts = {}) {
|
|
537
|
+
const client = new NodeClient(ctx);
|
|
538
|
+
const detail = await client.get(`/api/patches/${encodeURIComponent(id)}`).catch(() => null);
|
|
539
|
+
let retired = [];
|
|
540
|
+
const named = new Set((opts.supersede ?? []).flatMap((x) => x.split(',')).map((x) => x.trim()).filter(Boolean));
|
|
541
|
+
if (detail) {
|
|
542
|
+
// Item 248 — with --keep-others the overlaps stay listed on purpose (a dated snapshot), so there is nothing
|
|
543
|
+
// to confirm; without it, every listing this announce would retire has to be named, as before (item 150).
|
|
544
|
+
const retires = opts.keepOthers ? [] : retiredByAnnounce(detail);
|
|
545
|
+
retired = retires;
|
|
546
|
+
const missing = retires.filter((x) => !named.has(x.patch_id));
|
|
547
|
+
if (retires.length && !ctx.json) {
|
|
548
|
+
process.stderr.write([
|
|
549
|
+
c.warn('! ') + `announcing ${c.id(id)} retires ${retires.length} of your listing(s) on the same subject (${detail.anchor.benchmark.schema}) — permanently:`,
|
|
550
|
+
table(retires, [
|
|
551
|
+
{ key: 'id', title: 'RETIRED BY THIS ANNOUNCE', get: (x) => c.id(x.patch_id) },
|
|
552
|
+
{ key: 'st', title: 'STATUS', get: (x) => statusColor(x.status) },
|
|
553
|
+
{ key: 'sales', title: 'SALES', get: (x) => String(x.sales ?? 0), align: 'right' },
|
|
554
|
+
{ key: 'ov', title: 'SHARED ENTRIES', get: (x) => (x.overlap_rows ?? 0).toLocaleString('en-US'), align: 'right' },
|
|
555
|
+
]),
|
|
556
|
+
c.dim(' buyers of each one will see "Newer version available"; there is no undo.'),
|
|
557
|
+
].join('\n') + '\n');
|
|
558
|
+
}
|
|
559
|
+
if (missing.length) {
|
|
560
|
+
throw new CliError(`${id} would retire ${missing.map((x) => x.patch_id).join(', ')} — name them to go ahead:\n`
|
|
561
|
+
+ ` ${PROG} patch announce ${id} ${missing.map((x) => `--supersede ${x.patch_id}`).join(' ')}\n`
|
|
562
|
+
+ c.dim(` the draft is untouched; nothing was written to the ledger.`), 1, { retires: missing });
|
|
563
|
+
}
|
|
564
|
+
const coexisting = detail.conflicts.filter((x) => x.same_schema && (x.cross_branch || x.same_author === false));
|
|
565
|
+
if (coexisting.length && !ctx.json)
|
|
566
|
+
info(ctx, c.dim(` ${coexisting.length} other overlap(s) on the same subject stay as they are (another branch, or another node's knowledge — those are never retired by your publish).`));
|
|
567
|
+
}
|
|
568
|
+
const r = await client.post(`/api/patches/${encodeURIComponent(id)}/announce`,
|
|
569
|
+
// Item 248: the ids named here are what this version replaces — including ones whose rows do not overlap, which
|
|
570
|
+
// is the daily case (new listings, delistings) the automatic rule can never see.
|
|
571
|
+
{ replaces: [...named], ...(opts.keepOthers ? { auto_supersede: false } : {}) });
|
|
572
|
+
// What the NODE decided, which is what will actually be written when verifiers pass it.
|
|
573
|
+
if (r.retires)
|
|
574
|
+
retired = r.retires;
|
|
575
|
+
const v = r.verifiers;
|
|
576
|
+
const enough = !v || v.verifiers >= v.quorum;
|
|
577
|
+
ok(ctx, `announced ${c.id(id)} → ledger record ${shortHash(r.record.hash, 16)}${enough && v ? c.dim(` (${v.verifiers} verifier node(s) can attest; quorum is ${v.quorum})`) : ''}`);
|
|
578
|
+
if (v && !enough) {
|
|
579
|
+
warn(ctx, `this node knows ${v.verifiers} reachable verifier${v.verifiers === 1 ? '' : 's'} and the quorum is ${v.quorum}${v.known ? ` (${v.known} peer(s) known, ${v.reachable} answering)` : ''} — nothing announced here can be LISTED or sold until that changes.`);
|
|
580
|
+
process.stderr.write([
|
|
581
|
+
c.dim(` join a network: ${PROG} peers add <node url>`),
|
|
582
|
+
c.dim(` or verify alone: ${PROG} config set verifier.quorum 1 && ${PROG} config set verifier.allowSelfAttest true`),
|
|
583
|
+
].join('\n') + '\n');
|
|
584
|
+
}
|
|
585
|
+
if (retired.length)
|
|
586
|
+
info(ctx, c.dim(` when verifiers pass it, this retires: ${retired.map((x) => `${x.patch_id}${x.overlap_rows ? ` (${x.overlap_rows.toLocaleString('en-US')} shared rows)` : ' (declared — no shared rows)'}`).join(', ')}`));
|
|
587
|
+
else if (opts.keepOthers)
|
|
588
|
+
info(ctx, c.dim(' --keep-others: nothing of yours is retired by this publish; every overlapping version stays listed and on sale.'));
|
|
589
|
+
if (r.visibility === 'test')
|
|
590
|
+
warn(ctx, `${id} was published as a TEST listing: it is hidden from every public catalogue, and only this node can see it.`);
|
|
591
|
+
const out = { patch_id: id, record: r.record, retires: retired, verifiers: r.verifiers ?? null, visibility: r.visibility ?? 'public' };
|
|
592
|
+
// `publish` writes the one document for the whole operation; a bare `patch announce` writes its own (item 253)
|
|
593
|
+
if (opts.document !== false)
|
|
594
|
+
emit(ctx, { ...out, record_hash: r.record.hash, status: 'ANNOUNCED' }, () => undefined);
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* `ainize patch retire <id>` — the exit (item 148). `patch forget` deletes this node's copy of the file and keeps
|
|
599
|
+
* selling it; this writes an author-signed `retire` record: off the catalogue, 410 at the gateway, permanent.
|
|
600
|
+
*/
|
|
601
|
+
export async function patchRetire(ctx, id, opts = {}) {
|
|
602
|
+
const r = await new NodeClient(ctx).post(`/api/patches/${encodeURIComponent(id)}/retire`, { reason: opts.reason ?? '' });
|
|
603
|
+
emit(ctx, r, (x) => [
|
|
604
|
+
c.ok('✓ ') + `retired ${c.id(x.patch_id)} — off sale from now on${x.reason ? c.dim(` ("${x.reason}")`) : ''}`,
|
|
605
|
+
c.dim(' the anchor stays on the permanent record, and everyone who already bought it keeps their copy.'),
|
|
606
|
+
c.dim(` its gateway now answers 410 Gone; ${PROG} patch ls --status RETIRED still shows it to you.`),
|
|
607
|
+
].join('\n'));
|
|
608
|
+
return r;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* A Python traceback from the model container, turned into one English line (item 160).
|
|
612
|
+
*
|
|
613
|
+
* `Runtime.verify` rethrows the applier's stderr verbatim, so a failed verification reached the operator as eight
|
|
614
|
+
* lines of `scripts/patch.py` and `engram/live.py` internals ending in a Korean sentence — a stack trace from a file
|
|
615
|
+
* they do not own, in a language the rest of the product does not use, with no statement of what failed or where to
|
|
616
|
+
* look. The trace is not hidden (its last line is the container's own words, quoted as such); it is framed.
|
|
617
|
+
*/
|
|
618
|
+
export function runtimeFailure(err, nodeUrl, what) {
|
|
619
|
+
const e = err;
|
|
620
|
+
const msg = String(e?.message ?? '');
|
|
621
|
+
if (!/Traceback \(most recent call last\)|^apply failed:|patch hook reported failure/m.test(msg))
|
|
622
|
+
return err;
|
|
623
|
+
const lines = msg.trim().split('\n').map((s) => s.trim()).filter(Boolean);
|
|
624
|
+
const last = lines[lines.length - 1] ?? msg;
|
|
625
|
+
const frames = [...msg.matchAll(/File "([^"]+)", line (\d+)/g)].map((m) => `${m[1]}:${m[2]}`);
|
|
626
|
+
return new CliError([
|
|
627
|
+
`cannot load ${what} into the model served by ${nodeUrl} — the patch hook in the model container refused the write.`,
|
|
628
|
+
` the container said: ${last}`,
|
|
629
|
+
...(frames.length ? [c.dim(` it stopped in ${frames[frames.length - 1]}; the whole trace is in the node's own log (${PROG} logs --limit 50)`)] : [c.dim(` the whole trace is in the node's own log (${PROG} logs --limit 50)`)]),
|
|
630
|
+
c.dim(` nothing was attested and nothing about the knowledge changed; this node's verifier loop tries again on its own (${PROG} logs --kind verifier).`),
|
|
631
|
+
].join('\n'), e?.exitCode ?? 1, e?.details);
|
|
632
|
+
}
|
|
633
|
+
export async function patchVerify(ctx, id, opts = {}) {
|
|
634
|
+
const r = await new NodeClient(ctx).post(`/api/patches/${encodeURIComponent(id)}/verify`, opts.recheck ? { recheck: true } : {}, { timeoutMs: 30 * 60_000 })
|
|
635
|
+
.catch((err) => { throw runtimeFailure(err, ctx.nodeUrl, id); });
|
|
636
|
+
emit(ctx, r, (x) => {
|
|
637
|
+
const a = x.attestation;
|
|
638
|
+
// Item 340 — what the run cost, so a 4/4 and a 40/40 on a 2,761-fact knowledge stop reading the same.
|
|
639
|
+
const work = a.samples_run !== undefined
|
|
640
|
+
? c.dim(` ${a.samples_run} of ${a.samples_available ?? a.samples_run} question(s)${a.duration_ms !== undefined ? ` in ${Math.round(a.duration_ms / 1000)}s` : ''}`)
|
|
641
|
+
: '';
|
|
642
|
+
return `${a.passed ? c.ok('PASS') : c.err('FAIL')} ${id} on ${a.verified_on}${opts.recheck ? c.dim(' (recheck)') : ''} ${c.dim(JSON.stringify(a.score))}${work}`;
|
|
643
|
+
});
|
|
644
|
+
return r;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* `ainize patch challenge <id> --reason …`. A challenge is free to file and expensive for everyone else: it stops
|
|
648
|
+
* every sale of the knowledge and spends another operator's GPU minutes on the re-run (item 328). The command says
|
|
649
|
+
* that before it sends, and afterwards it reports what actually happens next — which, for a knowledge that was never
|
|
650
|
+
* LISTED, is not "off sale" but "the verifiers are asked to run it again" (item 242).
|
|
651
|
+
*/
|
|
652
|
+
export async function patchChallenge(ctx, id, reason) {
|
|
653
|
+
const client = new NodeClient(ctx);
|
|
654
|
+
const before = await client.get(`/api/patches/${encodeURIComponent(id)}`).catch(() => null);
|
|
655
|
+
if (before && !ctx.json) {
|
|
656
|
+
const sellable = before.sellable;
|
|
657
|
+
ok(ctx, c.dim(`${id} is ${before.status}${sellable ? ` and on sale at ${before.anchor.price} ${before.anchor.currency}` : ''}. A challenge is not a comment: ${sellable ? 'it stops every sale of this knowledge' : 'it asks the verifiers to run the benchmark again'}, and some other operator pays the GPU minutes for the re-run. Your address is on the record next to your reason.`));
|
|
658
|
+
}
|
|
659
|
+
const r = await client.post(`/api/patches/${encodeURIComponent(id)}/challenge`, { reason });
|
|
660
|
+
ok(ctx, `challenge recorded for ${id}: ${reason}`);
|
|
661
|
+
const after = await client.get(`/api/patches/${encodeURIComponent(id)}`).catch(() => null);
|
|
662
|
+
const status = after?.status ?? before?.status;
|
|
663
|
+
if (status === 'CHALLENGED')
|
|
664
|
+
ok(ctx, c.dim(`${id} is off sale until a verifier re-runs the benchmark and passes it; the author is told who challenged it and why`));
|
|
665
|
+
else
|
|
666
|
+
ok(ctx, c.dim(`${id} is ${status ?? 'not listed'} (${after ? verificationCount(after).fraction : '?'} passed) — it was not on sale, so nothing stops; the verifiers that have not answered your challenge will run the benchmark again`));
|
|
667
|
+
if (r.record)
|
|
668
|
+
ok(ctx, c.dim(`your challenges on this network: ${r.record.filed} filed · ${r.record.upheld} upheld · ${r.record.dismissed} dismissed by a re-run · ${r.record.open} waiting`));
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* `ainize patch price <id> <price>` — change what a published knowledge sells for (item 278).
|
|
672
|
+
*
|
|
673
|
+
* Anchors are immutable, so this was impossible: no discount, no raise, no "make it free". The only path was
|
|
674
|
+
* republishing, which supersedes your own item, restarts verification and splits its sales history — so every
|
|
675
|
+
* price was a one-shot guess made before a single sale. The change is a signed record on the ledger, it applies to
|
|
676
|
+
* the next sale, and it takes nothing away from anyone who already bought.
|
|
677
|
+
*/
|
|
678
|
+
export async function patchPrice(ctx, id, price, opts = {}) {
|
|
679
|
+
const client = new NodeClient(ctx);
|
|
680
|
+
const detail = await client.get(`/api/patches/${encodeURIComponent(id)}`);
|
|
681
|
+
const cur = detail.anchor.price;
|
|
682
|
+
const currency = detail.anchor.currency;
|
|
683
|
+
if (cur === price) {
|
|
684
|
+
ok(ctx, c.dim(`${id} already sells for ${Number(price) === 0 ? 'free' : `${price} ${currency}`} — nothing to change`));
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
info(ctx, [
|
|
688
|
+
`${c.id(id)} ${c.bold(`${cur} ${currency}`)} → ${c.bold(Number(price) === 0 ? 'free' : `${price} ${currency}`)}`,
|
|
689
|
+
c.dim(` the new price goes on the public record and applies to the next sale; ${detail.downloads} past sale(s) and everyone who already bought are untouched.`),
|
|
690
|
+
c.dim(' every price this knowledge has been sold at stays on the record, so buyers can see a discount is real.'),
|
|
691
|
+
].join('\n'));
|
|
692
|
+
await confirm(ctx, `Change the price of ${id} to ${Number(price) === 0 ? 'free' : `${price} ${currency}`}? [y/N]`, { yes: opts.yes });
|
|
693
|
+
}
|
|
694
|
+
const r = await client.post(`/api/patches/${encodeURIComponent(id)}/price`, { price, reason: opts.reason ?? '' });
|
|
695
|
+
emit(ctx, r, (x) => c.ok('✓ ') + `${c.id(x.patch_id)} now sells for ${Number(x.price) === 0 ? 'free' : `${x.price} ${x.currency}`}${x.previous !== x.price ? c.dim(` (was ${x.previous})`) : ''}`
|
|
696
|
+
+ (x.history.length > 1 ? '\n' + c.dim(` price history: ${x.history.map((h) => h.price).join(' → ')}`) : ''));
|
|
697
|
+
return r;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* The quote, printed before anything is spent (item 102). Every number here comes from the node: the price on the
|
|
701
|
+
* record, the bases this knowledge needs underneath it and what they cost (item 270), the balance the money leaves
|
|
702
|
+
* from, and — on a local-credit node — the fact that the credit was issued by that node and is not money (item 364).
|
|
703
|
+
*/
|
|
704
|
+
async function printQuote(ctx, client, detail, quote) {
|
|
705
|
+
const a = detail.anchor;
|
|
706
|
+
const chain = await client.get('/api/chain').catch(() => null);
|
|
707
|
+
const credit = quote.currency === 'CREDIT' ? await client.get('/api/me/credit').catch(() => null) : null;
|
|
708
|
+
const total = Number(quote.total);
|
|
709
|
+
const balance = chain?.balance ?? null;
|
|
710
|
+
const lines = [];
|
|
711
|
+
lines.push(`${c.id(a.id)}${a.name && a.name !== a.id ? ` · ${a.name}` : ''} ${c.bold(`${quote.price} ${quote.currency}`)}`);
|
|
712
|
+
lines.push(c.dim(` seller ${a.author_name ? `${a.author_name} ` : ''}${shortAddr(a.author, 8)} · ${a.rows.toLocaleString('en-US')} rows · ${a.model.id_M}`));
|
|
713
|
+
for (const r of quote.requires) {
|
|
714
|
+
const state = r.mine ? 'published here' : r.licensed ? 'already paid for on this node' : r.known ? `${r.price} ${r.currency}${r.author_name ? ` from ${r.author_name}` : ''}${r.held ? ' (body already here, not paid for)' : ''}` : 'price unknown — this node has never seen it';
|
|
715
|
+
lines.push(` ${c.dim('needs')} ${c.id(r.id)}${r.name && r.name !== r.id ? ` · ${r.name}` : ''} ${r.licensed || r.mine ? c.dim(state) : c.warn(state)}`);
|
|
716
|
+
}
|
|
717
|
+
if (quote.requires.length && !quote.missing.length)
|
|
718
|
+
lines.push(c.dim(' everything it needs underneath is already here'));
|
|
719
|
+
if (quote.missing.length)
|
|
720
|
+
lines.push(` ${c.head('total')} ${c.bold(`${quote.total} ${quote.currency}`)} ${c.dim(`(this knowledge + ${quote.missing.length} base${quote.missing.length === 1 ? '' : 's'} it cannot work without)`)}`);
|
|
721
|
+
if (balance !== null)
|
|
722
|
+
lines.push(c.dim(` balance ${balance} → ${Math.round((balance - total) * 1e6) / 1e6} ${quote.currency}`));
|
|
723
|
+
if (credit?.issuance.issues)
|
|
724
|
+
lines.push(c.dim(` ${credit.note}`));
|
|
725
|
+
// Item 236: who this price is divided between, BEFORE it is paid — the same `royaltyPlan` that will settle it,
|
|
726
|
+
// and the same numbers the receipt prints afterwards, so the two can be compared.
|
|
727
|
+
if (detail.split && detail.split.lines.length > 1) {
|
|
728
|
+
lines.push(c.dim(' each sale of this knowledge pays ') + detail.split.lines
|
|
729
|
+
.map((l) => `${c.bold(`${l.amount} ${detail.split.currency}`)} ${c.dim(`to ${l.name ?? shortAddr(l.address, 6)}${l.role === 'seller' ? ' (seller)' : l.knowledge.length ? ` (${l.role} of ${l.knowledge.join(', ')})` : ` (${l.role})`}`)}`).join(c.dim(' · ')));
|
|
730
|
+
}
|
|
731
|
+
if (detail.anchor.license)
|
|
732
|
+
lines.push(c.dim(` licence ${detail.anchor.license}`));
|
|
733
|
+
info(ctx, lines.join('\n'));
|
|
734
|
+
return { total, balance };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* `ainize patch buy <id>` — the quote, the decision, then the money (item 102).
|
|
738
|
+
*
|
|
739
|
+
* It used to print `✓ bought <id> for 25 (local-credit)` as its FIRST line: the price was never quoted, no
|
|
740
|
+
* confirmation existed anywhere in this CLI, and on an AIN node the first keystroke moved real money. Now the
|
|
741
|
+
* quote is printed, `--max-price` is checked against the family total (not just this item), a terminal is asked,
|
|
742
|
+
* and a non-terminal without `--yes` is refused instead of taken as a yes.
|
|
743
|
+
*/
|
|
744
|
+
export async function patchBuy(ctx, id, opts = {}) {
|
|
745
|
+
const o = typeof opts === 'boolean' ? { apply: opts } : opts;
|
|
746
|
+
const client = new NodeClient(ctx);
|
|
747
|
+
const detail = await client.get(`/api/patches/${encodeURIComponent(id)}`);
|
|
748
|
+
const quote = await client.get(`/api/patches/${encodeURIComponent(id)}/quote`);
|
|
749
|
+
// Item 271: this node has already paid for it. Collect on that receipt instead of running the 402 loop again —
|
|
750
|
+
// no quote, no confirmation, no charge. `--again` is the deliberate second purchase.
|
|
751
|
+
if (detail.purchased && !o.again) {
|
|
752
|
+
info(ctx, c.dim(`${id} was already paid for by this node — collecting the body on that receipt (nothing will be charged; \`--again\` buys a second time on purpose)`));
|
|
753
|
+
const done = await client.post(`/api/patches/${encodeURIComponent(id)}/buy`, { apply: !!o.apply }, { timeoutMs: 30 * 60_000 });
|
|
754
|
+
emitStep(ctx, !!o.batched, done, (x) => [
|
|
755
|
+
c.ok('✓ ') + `collected ${c.id(x.patch_id)} — nothing was charged (paid ${x.amount}${x.currency ? ` ${x.currency}` : ''} already, tx ${shortHash(x.tx_hash, 16)})`,
|
|
756
|
+
...x.steps.map((st) => ` ${c.head(st.step.padEnd(11))} ${st.detail}`),
|
|
757
|
+
c.dim(` body: ${x.path}`),
|
|
758
|
+
].join('\n'));
|
|
759
|
+
return done;
|
|
760
|
+
}
|
|
761
|
+
/*
|
|
762
|
+
* Items 173 / 343 — the file is already here because this node VERIFIED it, and the purchase is about the licence,
|
|
763
|
+
* not the bytes. Said before the money moves (the node's own timeline then prints "body already present", which on
|
|
764
|
+
* its own reads as "nothing happened"), and said again afterwards, naming what the payment actually bought.
|
|
765
|
+
*/
|
|
766
|
+
const heldUnlicensed = detail.has_body && !detail.purchased && !detail.owned;
|
|
767
|
+
if (heldUnlicensed) {
|
|
768
|
+
info(ctx, c.warn('! ') + `this node already holds the file for ${c.id(id)} — it fetched it to verify it, which is not a licence to use, teach on or subscribe with.`);
|
|
769
|
+
info(ctx, c.dim(` buying records the licence on the ledger and pays ${detail.anchor.author_name ?? shortAddr(detail.anchor.author, 8)} ${quote.price} ${quote.currency}; nothing is downloaded twice.`));
|
|
770
|
+
}
|
|
771
|
+
// Item 236: a version taken off the front of its own subject is a warning before the money, not a note after it.
|
|
772
|
+
if (detail.status === 'SUPERSEDED' && detail.superseded_by?.length) {
|
|
773
|
+
warn(ctx, `${c.id(id)} has been superseded: a newer version of the same subject exists → ${detail.superseded_by.map((x) => c.id(x)).join(', ')}`);
|
|
774
|
+
if (!o.allowSuperseded)
|
|
775
|
+
await confirm(ctx, `Buy the older ${id} anyway? [y/N]`, { yes: o.yes });
|
|
776
|
+
}
|
|
777
|
+
const { balance } = await printQuote(ctx, client, detail, quote);
|
|
778
|
+
/**
|
|
779
|
+
* The bases it cannot work without (design §13). The quote's `total` is the FAMILY price, and this command used to
|
|
780
|
+
* ask "Pay {total} for 2 knowledges?" and then pay for one — the bases were bought only with `--bundle`, and the
|
|
781
|
+
* prompt never mentioned the flag. So the family is offered here, in the terminal, and whatever is decided is what
|
|
782
|
+
* the confirmation then quotes: no answer buys a file that answers nothing until its base is under it, and no
|
|
783
|
+
* answer pays for a base nobody asked for.
|
|
784
|
+
*/
|
|
785
|
+
const named = quote.requires.filter((r) => quote.missing.includes(r.id));
|
|
786
|
+
let bundle = !!o.withRequired;
|
|
787
|
+
if (named.length && !bundle) {
|
|
788
|
+
const list = named.map((r) => `${r.name || r.id} (${r.known ? `${r.price} ${r.currency}` : 'price unknown'})`).join(', ');
|
|
789
|
+
bundle = await ask(ctx, `${c.id(id)} also needs ${list}; buy ${named.length === 1 ? 'both' : `all ${named.length + 1}`}? [y/N]`, { skip: !!o.yes });
|
|
790
|
+
if (!bundle)
|
|
791
|
+
info(ctx, c.warn(` buying ${id} alone — it will not answer anything until ${named.map((r) => r.id).join(' and ')} ${named.length === 1 ? 'is' : 'are'} loaded under it (\`${PROG} patch buy ${id} --bundle\` buys the family)`));
|
|
792
|
+
}
|
|
793
|
+
const pay = bundle ? Number(quote.total) : Number(quote.price);
|
|
794
|
+
if (o.maxPrice !== undefined && pay > o.maxPrice) {
|
|
795
|
+
throw new CliError(`${id} costs ${bundle ? quote.total : quote.price} ${quote.currency}${bundle && quote.missing.length ? ` with the ${quote.missing.length} base(s) it needs` : ''} — over --max-price ${o.maxPrice}. Nothing was bought.`);
|
|
796
|
+
}
|
|
797
|
+
if (balance !== null && balance < pay) {
|
|
798
|
+
throw new CliError(`this node holds ${balance} ${quote.currency} and the purchase costs ${bundle ? quote.total : quote.price} — nothing was bought`);
|
|
799
|
+
}
|
|
800
|
+
/*
|
|
801
|
+
* Item 277 — a free knowledge is not bought. Nothing is charged, no identity is needed and no sale is recorded,
|
|
802
|
+
* so asking "Pay 0 CREDIT?" was both untrue and the last thing between a visitor and the cheapest way in.
|
|
803
|
+
* Item 351 — and when money DOES move, the record naming this node as the buyer is public on every peer. That
|
|
804
|
+
* was learned afterwards, from the ledger page; it is said here, before the answer.
|
|
805
|
+
*/
|
|
806
|
+
const freeNow = pay === 0;
|
|
807
|
+
if (freeNow)
|
|
808
|
+
info(ctx, c.dim(` free — nothing will be charged, and no public record will name this node as a buyer`));
|
|
809
|
+
else
|
|
810
|
+
info(ctx, c.dim(` the sale is recorded publicly: ${id}, ${bundle ? quote.total : quote.price} ${quote.currency} and this node's address, on every peer's ledger`));
|
|
811
|
+
await confirm(ctx, heldUnlicensed
|
|
812
|
+
? `Pay ${quote.price} ${quote.currency} to license ${id} (the file is already here)? [y/N]`
|
|
813
|
+
: freeNow
|
|
814
|
+
? `Download ${id} for free${bundle && named.length ? ` with ${named.length} base${named.length === 1 ? '' : 's'}` : ''}? [Y/n]`
|
|
815
|
+
: `Pay ${bundle ? quote.total : quote.price} ${quote.currency}${bundle && named.length ? ` for ${named.length + 1} knowledges` : ''}? [y/N]`, { yes: o.yes || freeNow });
|
|
816
|
+
const seller = detail.anchor.author_name ?? shortAddr(detail.anchor.author, 8);
|
|
817
|
+
const r = await client.post(`/api/patches/${encodeURIComponent(id)}/buy`, { apply: !!o.apply, bundle, max_total: o.maxPrice, again: !!o.again }, { timeoutMs: 30 * 60_000 })
|
|
818
|
+
.catch((err) => { throw refusalError(err, { seller, id, price: bundle ? quote.total : quote.price, currency: quote.currency, balance }); });
|
|
819
|
+
emit(ctx, r, (x) => {
|
|
820
|
+
const t0 = x.steps[0]?.at ?? Date.now();
|
|
821
|
+
const cur = x.currency ?? quote.currency;
|
|
822
|
+
const head = x.redeemed
|
|
823
|
+
? c.ok('✓ ') + `collected ${c.id(x.patch_id)} against the payment already made — nothing was charged tx ${shortHash(x.tx_hash, 16)}`
|
|
824
|
+
: Number(x.total ?? x.amount) === 0
|
|
825
|
+
// Item 277: "bought ux4-free for 0 (local-credit)" described a ceremony that no longer happens.
|
|
826
|
+
? c.ok('✓ ') + `downloaded ${c.id(x.patch_id)} — free, nothing was charged and no sale was recorded`
|
|
827
|
+
: c.ok('✓ ') + `bought ${c.id(x.patch_id)} for ${x.total ?? x.amount} ${cur} (${x.scheme}) tx ${shortHash(x.tx_hash, 16)}`;
|
|
828
|
+
const bought = (x.purchases ?? []).filter((pp) => pp.patch_id !== x.patch_id);
|
|
829
|
+
return [
|
|
830
|
+
head,
|
|
831
|
+
...bought.map((pp) => c.dim(` with its base ${pp.patch_id}: ${pp.amount} ${pp.currency} tx ${shortHash(pp.tx_hash, 12)}`)),
|
|
832
|
+
...x.steps.map((s) => ` ${c.dim(`+${String(s.at - t0).padStart(5)}ms`)} ${c.head(s.step.padEnd(9))} ${s.detail}`),
|
|
833
|
+
// Item 280: who the money actually went to, from the seller's own `x-payment-response` — the product's
|
|
834
|
+
// central promise to a buyer, shown at the moment it is kept instead of only on the seller's log.
|
|
835
|
+
...payeeLines(x, cur),
|
|
836
|
+
// Item 343: the node's own timeline says "body already present"; what the money bought is said here.
|
|
837
|
+
...(heldUnlicensed ? [c.dim(` the file was already on this node (fetched to verify it) — what ${x.total ?? x.amount} ${cur} bought is the licence: settlement ${shortHash(x.tx_hash, 16)}, and ${id} may now be used, taught on and subscribed with here`)] : []),
|
|
838
|
+
c.dim(` body: ${x.path}`),
|
|
839
|
+
].join('\n');
|
|
840
|
+
});
|
|
841
|
+
return r;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* A seller's refusal, as a sentence (item 293).
|
|
845
|
+
*
|
|
846
|
+
* `market.buy` throws `payment rejected: 402 {"error":"insufficient credit: 2 < 5"}` and every surface printed it
|
|
847
|
+
* verbatim: at the moment a purchase fails, the buyer was shown a status code and a JSON blob. The seller's reasons
|
|
848
|
+
* are readable sentences on the other side; this unwraps them and names the seller, the balance and the price.
|
|
849
|
+
*/
|
|
850
|
+
export function refusalError(err, ctxInfo) {
|
|
851
|
+
const e = err;
|
|
852
|
+
const msg = String(e?.message ?? '');
|
|
853
|
+
const m = /^payment rejected: (\d{3}) ([\s\S]*)$/.exec(msg);
|
|
854
|
+
if (!m)
|
|
855
|
+
return err;
|
|
856
|
+
let detail = m[2].trim();
|
|
857
|
+
try {
|
|
858
|
+
const j = JSON.parse(detail);
|
|
859
|
+
detail = String(j.error ?? j.message ?? detail);
|
|
860
|
+
}
|
|
861
|
+
catch { /* the seller answered prose */ }
|
|
862
|
+
const credit = /insufficient credit:\s*([\d.]+)\s*<\s*([\d.]+)/.exec(detail);
|
|
863
|
+
const line = credit
|
|
864
|
+
? `${ctxInfo.seller} refused the payment: this node has ${credit[1]} ${ctxInfo.currency} and ${ctxInfo.id} costs ${credit[2]}.`
|
|
865
|
+
: /transfer (not found|not executed)|no such transfer/i.test(detail)
|
|
866
|
+
? `${ctxInfo.seller} could not confirm the payment on the chain (${detail}) — nothing was delivered; the transfer, if it went through, is on your wallet.`
|
|
867
|
+
: /does not sell|not for sale|unknown patch/i.test(detail)
|
|
868
|
+
? `${ctxInfo.seller} does not sell ${ctxInfo.id} (${detail}) — \`${PROG} patch get ${ctxInfo.id}\` shows where it is sold today.`
|
|
869
|
+
: `${ctxInfo.seller} refused the payment (HTTP ${m[1]}): ${detail}`;
|
|
870
|
+
const next = credit && ctxInfo.balance !== null
|
|
871
|
+
? `\n this node's balance is ${ctxInfo.balance} ${ctxInfo.currency}; ${PROG} wallet shows where it comes from.`
|
|
872
|
+
: '';
|
|
873
|
+
return new CliError(line + next, e?.exitCode ?? 1, e?.details);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* `ainize patch download <id>` — collect a knowledge this node has ALREADY paid for, without paying again (item 273).
|
|
877
|
+
*
|
|
878
|
+
* The recovery path existed in the node and was invisible: a buyer whose manifest was lost, or whose purchase died
|
|
879
|
+
* between the transfer and the download (item 274), was told "payment already used" and offered "Buy again".
|
|
880
|
+
*/
|
|
881
|
+
export async function patchDownload(ctx, id) {
|
|
882
|
+
const r = await new NodeClient(ctx).post(`/api/patches/${encodeURIComponent(id)}/collect`, {}, { timeoutMs: 30 * 60_000 });
|
|
883
|
+
emit(ctx, r, (x) => {
|
|
884
|
+
const t0 = x.steps[0]?.at ?? Date.now();
|
|
885
|
+
return [
|
|
886
|
+
c.ok('✓ ') + `collected ${c.id(x.patch_id)} — no payment (paid ${x.amount}${x.currency ? ` ${x.currency}` : ''}, tx ${shortHash(x.tx_hash, 16)})`,
|
|
887
|
+
...x.steps.map((s) => ` ${c.dim(`+${String(s.at - t0).padStart(5)}ms`)} ${c.head(s.step.padEnd(11))} ${s.detail}`),
|
|
888
|
+
c.dim(` body: ${x.path}`),
|
|
889
|
+
].join('\n');
|
|
890
|
+
});
|
|
891
|
+
return r;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Apply/remove as a JOB (item 212). The shared model lock has no upper bound — another node's live test or a
|
|
895
|
+
* verification can hold it for minutes — and the synchronous POST used to die on the HTTP client's own header
|
|
896
|
+
* timeout: `cannot reach node … (fetch failed)`, exit 2, five minutes before the node ran the operation anyway.
|
|
897
|
+
* The node answers 202 with a job now; this prints where it is in the queue and waits for the real answer.
|
|
898
|
+
*/
|
|
899
|
+
async function runRuntimeJob(ctx, kind, id, body, batched = false) {
|
|
900
|
+
const client = new NodeClient(ctx);
|
|
901
|
+
const path = `/api/patches/${encodeURIComponent(id)}/${kind === 'apply' ? 'apply' : 'remove'}`;
|
|
902
|
+
const first = await client.post(path, { ...body, async: true }, { timeoutMs: 120_000 });
|
|
903
|
+
/**
|
|
904
|
+
* Item 220 — under `--json` these verbs printed nothing at all on success: a script that loaded a set got zero
|
|
905
|
+
* bytes and exit 0 from every step, and had to read `/api/runtime` afterwards to guess what each one did. The
|
|
906
|
+
* stack is what the caller actually wants ("what is loaded now, in what order"), so it is fetched once, for the
|
|
907
|
+
* document only, and never for a terminal that has just been told the same thing in a sentence.
|
|
908
|
+
*/
|
|
909
|
+
const done = async (result) => {
|
|
910
|
+
const stack = ctx.json && !batched ? await client.get('/api/runtime/stack').then((x) => x.stack).catch(() => null) : null;
|
|
911
|
+
emitStep(ctx, batched, { patch_id: id, kind, result, applied: stack }, () => c.ok('✓ ') + `${kind === 'apply' ? 'applied' : 'removed'} ${id}: ${result}`);
|
|
912
|
+
return result;
|
|
913
|
+
};
|
|
914
|
+
// A node from before jobs existed answers synchronously; keep working with it.
|
|
915
|
+
if (!first.job)
|
|
916
|
+
return done(String(first.result ?? ''));
|
|
917
|
+
let job = first.job;
|
|
918
|
+
let said = false;
|
|
919
|
+
for (;;) {
|
|
920
|
+
if (job.state === 'done')
|
|
921
|
+
return done(job.result ?? '');
|
|
922
|
+
// A refusal from the model container arrives here as its own Python trace too (item 160).
|
|
923
|
+
if (job.state === 'failed')
|
|
924
|
+
throw runtimeFailure(new CliError(job.error ?? `${kind} failed`, job.status === 409 ? 5 : 1), ctx.nodeUrl, id);
|
|
925
|
+
if (!said && job.state === 'queued') {
|
|
926
|
+
const holder = job.queue?.running ?? (job.queue?.lock ? { label: job.queue.lock.label, since: job.queue.lock.since } : null);
|
|
927
|
+
info(ctx, c.dim(holder
|
|
928
|
+
? `queued behind ${holder.label} (started ${fmtTime(holder.since)}) — Ctrl-C leaves it queued on the node; \`${PROG} patch stack\` shows the result`
|
|
929
|
+
: `queued on the node — Ctrl-C leaves it queued; \`${PROG} patch stack\` shows the result`));
|
|
930
|
+
said = true;
|
|
931
|
+
}
|
|
932
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
933
|
+
job = (await client.get(`/api/runtime/jobs/${job.id}`, { timeoutMs: 30_000 })).job;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
export async function patchApply(ctx, id, opts = {}) {
|
|
937
|
+
return runRuntimeJob(ctx, 'apply', id, { with_base: !!opts.withBase }, !!opts.batched);
|
|
938
|
+
}
|
|
939
|
+
export async function patchRemove(ctx, id, opts = {}) {
|
|
940
|
+
return runRuntimeJob(ctx, 'remove', id, { cascade: !!opts.cascade }, !!opts.batched);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* `ainize use a b`, `patch apply a b`, `patch buy a,b` — a set, in the order given (item 219).
|
|
944
|
+
*
|
|
945
|
+
* The product's stated purpose is combining several knowledges, and `use a,b` answered `patch not found` while
|
|
946
|
+
* `patch apply a b` answered `Unknown argument: b`: the set had to be assembled one command at a time, with no
|
|
947
|
+
* stated order. The order typed IS the load order, and the last one wins on any memory entry two of them share —
|
|
948
|
+
* so the run ends by saying which order that was. Under `--json` the batch writes one document, not one per step.
|
|
949
|
+
*/
|
|
950
|
+
export async function overIds(ctx, ids, verb, one) {
|
|
951
|
+
const many = ids.length > 1;
|
|
952
|
+
const out = [];
|
|
953
|
+
for (const id of ids)
|
|
954
|
+
out.push(await one(id, many));
|
|
955
|
+
if (!many)
|
|
956
|
+
return out;
|
|
957
|
+
if (verb && !ctx.json)
|
|
958
|
+
info(ctx, c.dim(verb === 'loaded'
|
|
959
|
+
? `loaded in order: ${ids.join(' → ')} (the last one wins on any memory entry they share)`
|
|
960
|
+
: `${verb}: ${ids.join(', ')}`));
|
|
961
|
+
emit(ctx, { ids, items: out }, () => undefined);
|
|
962
|
+
return out;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* `a,b`, `a b` and repeated flags all mean the same set, in the order typed (item 219). `chat` had this and
|
|
966
|
+
* nothing else did, so the comma form was sent to the node verbatim as one id and came back "patch not found".
|
|
967
|
+
*/
|
|
968
|
+
export function parseIds(input) {
|
|
969
|
+
const raw = Array.isArray(input) ? input : input === undefined ? [] : [input];
|
|
970
|
+
const ids = [...new Set(raw.flatMap((x) => String(x).split(/[,\s]+/)).map((x) => x.trim()).filter(Boolean))];
|
|
971
|
+
if (!ids.length)
|
|
972
|
+
throw new CliError(`knowledge id required — \`${PROG} patch ls\` lists what this node knows about`);
|
|
973
|
+
return ids;
|
|
974
|
+
}
|
|
975
|
+
/** Why a layer is on the table, in the operator's words (`applied.reason`). */
|
|
976
|
+
function layerReason(reason) {
|
|
977
|
+
if (reason === 'manual')
|
|
978
|
+
return 'loaded by hand';
|
|
979
|
+
if (reason.startsWith('subscription:'))
|
|
980
|
+
return `loaded by the track ${reason.slice('subscription:'.length)}`;
|
|
981
|
+
if (reason.startsWith('chat:'))
|
|
982
|
+
return 'loaded for a live test';
|
|
983
|
+
return `loaded: ${reason}`;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* `ainize patch stack` — what is loaded in the serving model, bottom first, and what each layer sits on.
|
|
987
|
+
*
|
|
988
|
+
* Item 216: the position, why the layer is there and when it was loaded were all in the answer and none of them on
|
|
989
|
+
* screen, and the rows two neighbouring layers share — the thing that decides which of them the model actually
|
|
990
|
+
* answers from — were nowhere at all. Positions are 1-based here and in `patch ls`'s LOADED column, so the two
|
|
991
|
+
* listings cannot disagree about where a knowledge sits.
|
|
992
|
+
*/
|
|
993
|
+
export async function patchStack(ctx) {
|
|
994
|
+
const client = new NodeClient(ctx);
|
|
995
|
+
const r = await client.get('/api/runtime/stack');
|
|
996
|
+
// What each layer shares with the one directly under it (the pair that decides an answer).
|
|
997
|
+
const shared = new Map();
|
|
998
|
+
for (const [i, l] of r.stack.entries()) {
|
|
999
|
+
if (i === 0)
|
|
1000
|
+
continue;
|
|
1001
|
+
const below = r.stack[i - 1].patch_id;
|
|
1002
|
+
const conf = await client.get(`/api/patches/${encodeURIComponent(l.patch_id)}/conflicts`).catch(() => null);
|
|
1003
|
+
const hit = conf?.conflicts.find((x) => x.patch_id === below);
|
|
1004
|
+
if (hit?.overlap_rows)
|
|
1005
|
+
shared.set(l.patch_id, hit.overlap_rows);
|
|
1006
|
+
}
|
|
1007
|
+
emit(ctx, r.stack, () => {
|
|
1008
|
+
if (!r.stack.length)
|
|
1009
|
+
return c.dim('nothing is loaded in the serving model');
|
|
1010
|
+
return [
|
|
1011
|
+
...r.stack.map((l, i) => [
|
|
1012
|
+
`${`#${i + 1}`.padStart(3)} ${l.patch_id}${l.name ? c.dim(` — ${l.name}`) : ''}`,
|
|
1013
|
+
c.dim(` ${l.rows !== null ? `${l.rows.toLocaleString()} rows · ` : ''}${layerReason(l.reason)} · ${fmtTime(l.applied_at)}`),
|
|
1014
|
+
c.dim(` ${l.export === 'delta' ? `add-on, needs ${l.base_stack.join(', ')} underneath` : l.export === 'squash' ? 'stand-alone build' : 'published before add-ons existed'}`),
|
|
1015
|
+
shared.has(l.patch_id) ? c.warn(` shares ${shared.get(l.patch_id).toLocaleString('en-US')} entries with ${r.stack[i - 1].patch_id} below it — this one answers on them`) : '',
|
|
1016
|
+
c.dim(` ${l.journal ? 'can be unloaded without disturbing what is under it' : 'no journal — unloading it writes the model\'s own rows back'}${l.body_present ? '' : ' · body no longer on this node'}`),
|
|
1017
|
+
].filter(Boolean).join('\n')),
|
|
1018
|
+
c.dim(`\nthe last line is on top: it wins on any row two of them share`),
|
|
1019
|
+
].join('\n');
|
|
1020
|
+
});
|
|
1021
|
+
return r.stack;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* `ainize patch tree <id>` — the family tree as text: ancestors above, this knowledge, then what was built on it,
|
|
1025
|
+
* each line saying what that knowledge ADDED. The relation is the child's own claim ("built on" / "newer version" /
|
|
1026
|
+
* "correction" / "combined"), and `declared` prints as the honest "declared parent — not trained on top" (§14).
|
|
1027
|
+
*/
|
|
1028
|
+
export async function patchTree(ctx, id, opts = {}) {
|
|
1029
|
+
const r = await new NodeClient(ctx).get(`/api/patches/${encodeURIComponent(id)}/tree${query({ depth: opts.depth, dir: opts.dir })}`);
|
|
1030
|
+
const byId = new Map(r.nodes.map((n) => [n.id, n]));
|
|
1031
|
+
// The same edge reads differently from each end: looking UP at a base, `version` means "this replaces it"; looking
|
|
1032
|
+
// DOWN at a derivative it means "a newer version of this". One map per direction, so no line is true backwards.
|
|
1033
|
+
const relDown = { extend: 'built on it', update: 'newer version', contradict: 'correction', merge: 'combined from', version: 'newer version', track: 'different context', declared: 'declared parent — not trained on top' };
|
|
1034
|
+
const relUp = { extend: 'this was built on it', update: 'this replaces it', contradict: 'this corrects it', merge: 'combined from it', version: 'this replaces it', track: 'a different context of it', declared: 'declared parent — not trained on top' };
|
|
1035
|
+
const plural = (n, one) => `${n.toLocaleString('en-US')} ${one}${n === 1 ? '' : 's'}`;
|
|
1036
|
+
const label = (n) => {
|
|
1037
|
+
if (n.missing)
|
|
1038
|
+
return `${c.dim(n.id)} ${c.dim('(not on this node)')}`;
|
|
1039
|
+
const added = `+${n.added.questions} questions · ${n.added.changed} changed · ${n.added.rows.toLocaleString('en-US')} rows (${n.added.new.toLocaleString('en-US')} new)`;
|
|
1040
|
+
const sig = `${plural(n.signals.sales_all ?? 0, 'sale')} · loaded on ${plural(n.signals.loads ?? 0, 'node')} · built on ${n.signals.built_on ?? 0}×`;
|
|
1041
|
+
return `${c.id(n.id)}${n.name && n.name !== n.id ? ` — ${n.name}` : ''}\n ${c.dim(added)}\n ${c.dim(sig)}`;
|
|
1042
|
+
};
|
|
1043
|
+
emit(ctx, r, () => {
|
|
1044
|
+
const lines = [];
|
|
1045
|
+
const children = (from) => r.edges.filter((e) => e.from === from);
|
|
1046
|
+
// One line per KNOWLEDGE above the root, not per edge: ep12 is both a declared parent of krx-all and a version
|
|
1047
|
+
// of it, and printing it twice made the demo node's tree read as two different knowledges with the same name.
|
|
1048
|
+
const above = new Map();
|
|
1049
|
+
for (const e of r.edges.filter((x) => x.to === r.root))
|
|
1050
|
+
above.set(e.from, [...(above.get(e.from) ?? []), relUp[e.kind] ?? e.kind]);
|
|
1051
|
+
for (const [id, kinds] of above) {
|
|
1052
|
+
const n = byId.get(id);
|
|
1053
|
+
const onlyVersion = kinds.every((k) => k === relUp.version);
|
|
1054
|
+
if (n)
|
|
1055
|
+
lines.push(` ${c.dim(onlyVersion ? 'older' : 'base')} ${label(n)} ${c.dim(`(${[...new Set(kinds)].join(', ')})`)}`);
|
|
1056
|
+
}
|
|
1057
|
+
if (above.size)
|
|
1058
|
+
lines.push(c.dim(' ↓'));
|
|
1059
|
+
const root = byId.get(r.root);
|
|
1060
|
+
if (root)
|
|
1061
|
+
lines.push(` ${c.dim('this')} ${label(root)}`);
|
|
1062
|
+
const kids = children(r.root);
|
|
1063
|
+
if (kids.length)
|
|
1064
|
+
lines.push(c.dim(' ↓'));
|
|
1065
|
+
const seen = new Set([r.root]);
|
|
1066
|
+
const walk = (from, indent) => {
|
|
1067
|
+
for (const e of children(from)) {
|
|
1068
|
+
const n = byId.get(e.to);
|
|
1069
|
+
if (!n || seen.has(e.to))
|
|
1070
|
+
continue;
|
|
1071
|
+
seen.add(e.to);
|
|
1072
|
+
lines.push(`${indent}${c.dim(relDown[e.kind] ?? e.kind)} ${label(n)}`);
|
|
1073
|
+
walk(e.to, `${indent} `);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
walk(r.root, ' ');
|
|
1077
|
+
if (!above.size && !kids.length)
|
|
1078
|
+
lines.push(c.dim(' nothing was built on this, and it was not built on anything'));
|
|
1079
|
+
lines.push('');
|
|
1080
|
+
lines.push(c.dim(`this family: ${r.family.sales} sales · ${r.family.knowledges} knowledges · ${r.family.authors} creators`));
|
|
1081
|
+
if (r.money.lineage_pct > 0)
|
|
1082
|
+
lines.push(c.dim(`each sale: ${r.money.seller_pct}% to ${r.money.seller_name ?? 'the seller'}, ${r.money.lineage_pct}% shared by the creators of ${r.money.lineage_names.join(', ')}`));
|
|
1083
|
+
if (r.money.contributor_pct > 0)
|
|
1084
|
+
lines.push(c.dim(` and ${r.money.contributor_pct}% to this knowledge's own credited teacher${r.money.recipients.filter((x) => x.kind === 'contributor').map((x) => ` (${x.name ?? shortAddr(x.address, 8)})`).join('')}`));
|
|
1085
|
+
if (r.truncated)
|
|
1086
|
+
lines.push(c.dim(`(stopped at depth ${r.depth} — ask for more with --depth)`));
|
|
1087
|
+
return lines.join('\n');
|
|
1088
|
+
});
|
|
1089
|
+
return r;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* `ainize patch missing <id>` — the open questions of a knowledge. A row with no text is not a bug: the question was
|
|
1093
|
+
* counted without being kept, because nobody consented to share it (§10).
|
|
1094
|
+
*/
|
|
1095
|
+
export async function patchMissing(ctx, id, opts = {}) {
|
|
1096
|
+
const r = await new NodeClient(ctx).get(`/api/patches/${encodeURIComponent(id)}/issues${query({ kind: opts.kind, limit: opts.limit, status: opts.all ? 'all' : 'open' })}`);
|
|
1097
|
+
const kindText = { own_miss: 'its own question, got wrong here', preflight: 'someone tried to teach it on top', free_wrong: 'a free question marked wrong', request: 'a buyer asked for it', gap: 'coverage gap' };
|
|
1098
|
+
emit(ctx, r.items, (rows) => table(rows, [
|
|
1099
|
+
{ key: 'k', title: 'WHY', get: (x) => kindText[x.kind] ?? x.kind },
|
|
1100
|
+
{ key: 'q', title: 'QUESTION', get: (x) => x.text ?? c.dim('not shared — counted only') },
|
|
1101
|
+
{ key: 'n', title: 'ASKED', get: (x) => `${x.count}×`, align: 'right' },
|
|
1102
|
+
{ key: 'p', title: 'PEOPLE', get: (x) => String(x.people), align: 'right' },
|
|
1103
|
+
{ key: 's', title: 'STATUS', get: (x) => (x.covered_by ? c.ok(`covered by ${x.covered_by}`) : 'open') },
|
|
1104
|
+
], 'nothing reported yet — load it in Chat and ask around'));
|
|
1105
|
+
return r.items;
|
|
1106
|
+
}
|
|
1107
|
+
/** `ainize patch signals <id>` — what it is doing, with the two scopes kept apart (SC-11). */
|
|
1108
|
+
export async function patchSignals(ctx, id) {
|
|
1109
|
+
const r = await new NodeClient(ctx).get(`/api/patches/${encodeURIComponent(id)}/signals`);
|
|
1110
|
+
emit(ctx, { network: r.network, node: r.node }, () => [
|
|
1111
|
+
c.dim('network — read from the ledger and the peers, the same on every node'),
|
|
1112
|
+
kv([
|
|
1113
|
+
['sales', `${r.network.sales_all} (${r.network.sales_30d} in 30 days)`],
|
|
1114
|
+
['buyers', String(r.network.buyers)], ['revenue', String(r.network.revenue)],
|
|
1115
|
+
['loaded on', `${r.network.loads} nodes`], ['built on', `${r.network.built_on} knowledges`],
|
|
1116
|
+
['versions', String(r.network.versions)], ['track subscribers', String(r.network.subscribers)],
|
|
1117
|
+
['verified', `${r.network.passed}/${r.network.quorum}`],
|
|
1118
|
+
]),
|
|
1119
|
+
'',
|
|
1120
|
+
c.dim(`this node — last ${r.node.window_days} days, this node only`),
|
|
1121
|
+
kv([
|
|
1122
|
+
['live tests', `${r.node.tests} (✓${r.node.hits} ✗${r.node.misses} unscored ${r.node.unscored})`],
|
|
1123
|
+
['marked wrong', String(r.node.marked_wrong)], ['visitors', String(r.node.visitors)],
|
|
1124
|
+
['pre-flight on top of it', `${r.node.preflight_wrong_today} new · ${r.node.preflight_in_base} already answered · ${r.node.preflight_base_conflict} disagreeing`],
|
|
1125
|
+
['questions fetched', String(r.node.derive_fetches)], ['lessons built on it', String(r.node.builds_on_jobs)],
|
|
1126
|
+
['open questions', String(r.node.open_questions)],
|
|
1127
|
+
]),
|
|
1128
|
+
].join('\n'));
|
|
1129
|
+
return { network: r.network, node: r.node };
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* `ainize patch conflicts <id>` — the bodies that write the same memory rows, and which of them is winning (item 216).
|
|
1133
|
+
*
|
|
1134
|
+
* The table used to print an overlap and stop, leaving the only question that matters — whose answer does the model
|
|
1135
|
+
* actually give on those rows — unanswerable from any surface. An overlap decides nothing until both are loaded;
|
|
1136
|
+
* when they are, the one loaded later wins, and that is what the row says.
|
|
1137
|
+
*/
|
|
1138
|
+
export async function patchConflicts(ctx, id) {
|
|
1139
|
+
const client = new NodeClient(ctx);
|
|
1140
|
+
const r = await client.get(`/api/patches/${encodeURIComponent(id)}/conflicts`);
|
|
1141
|
+
const loaded = await loadedPositions(client);
|
|
1142
|
+
const mine = loaded.get(id) ?? null;
|
|
1143
|
+
const verdict = (x) => {
|
|
1144
|
+
const theirs = loaded.get(x.patch_id) ?? null;
|
|
1145
|
+
if (theirs === null && mine === null)
|
|
1146
|
+
return c.dim('neither is loaded — nothing is overridden');
|
|
1147
|
+
if (theirs === null)
|
|
1148
|
+
return c.dim(`only ${id} is loaded — its answers stand`);
|
|
1149
|
+
if (mine === null)
|
|
1150
|
+
return c.warn(`${x.patch_id} is loaded and ${id} is not — the model answers from ${x.patch_id}`);
|
|
1151
|
+
return theirs > mine
|
|
1152
|
+
? c.warn(`loaded above (#${theirs} > #${mine}): its answers win on the ${x.overlap_rows.toLocaleString('en-US')} shared rows`)
|
|
1153
|
+
: c.ok(`loaded below (#${theirs} < #${mine}): ${id} wins on the ${x.overlap_rows.toLocaleString('en-US')} shared rows`);
|
|
1154
|
+
};
|
|
1155
|
+
emit(ctx, r.conflicts, (rows) => table(rows, [
|
|
1156
|
+
{ key: 'p', title: 'PATCH', get: (x) => x.patch_id },
|
|
1157
|
+
{ key: 'l', title: 'LOADED', get: (x) => (loaded.has(x.patch_id) ? c.ok(`#${loaded.get(x.patch_id)}`) : c.dim('-')), align: 'right' },
|
|
1158
|
+
{ key: 'o', title: 'SHARED ROWS', get: (x) => x.overlap_rows.toLocaleString('en-US'), align: 'right' },
|
|
1159
|
+
{ key: 's', title: 'SAME SCHEMA', get: (x) => (x.same_schema ? c.warn('yes') : 'no') }, { key: 'st', title: 'STATUS', get: (x) => statusColor(x.status) },
|
|
1160
|
+
{ key: 'w', title: 'WHICH ONE ANSWERS', get: verdict },
|
|
1161
|
+
], 'no address-set overlap with any patch body held by this node')
|
|
1162
|
+
+ `\n${c.dim(mine === null ? `${id} is not loaded in the serving model (${PROG} patch apply ${id})` : `${id} is loaded at #${mine} (${PROG} patch stack shows the whole stack)`)}`);
|
|
1163
|
+
return r.conflicts;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* `ainize purchases` — what this node bought, from whom, for how much, and whether it is loaded.
|
|
1167
|
+
*
|
|
1168
|
+
* `patch ls --mine` is "knowledge I registered" and hid every purchase (item 216), so a buyer's own node could not
|
|
1169
|
+
* list what it owned; and the purchase row itself names no counterparty (item 289), which is the first thing anyone
|
|
1170
|
+
* needs for a refund, a dispute or an audit. The seller is resolved here from the anchor the same call returns.
|
|
1171
|
+
*/
|
|
1172
|
+
export async function purchasesLs(ctx) {
|
|
1173
|
+
const client = new NodeClient(ctx);
|
|
1174
|
+
const r = await client.get('/api/me/purchases');
|
|
1175
|
+
const loaded = await loadedPositions(client);
|
|
1176
|
+
const seller = (p) => {
|
|
1177
|
+
const a = p.entry?.anchor;
|
|
1178
|
+
if (!a)
|
|
1179
|
+
return c.dim('unknown — this node no longer holds that anchor');
|
|
1180
|
+
return a.author_name ? `${a.author_name} ${c.dim(shortAddr(a.author, 4))}` : shortAddr(a.author, 8);
|
|
1181
|
+
};
|
|
1182
|
+
emit(ctx, r.items, (rows) => table(rows, [
|
|
1183
|
+
{ key: 'id', title: 'KNOWLEDGE', get: (p) => c.id(p.patch_id) },
|
|
1184
|
+
{ key: 'n', title: 'NAME', get: (p) => p.entry?.anchor.name ?? c.dim('-') },
|
|
1185
|
+
{ key: 'a', title: 'PAID', get: (p) => `${p.amount}${p.entry ? ` ${p.entry.anchor.currency}` : ''}`, align: 'right' },
|
|
1186
|
+
{ key: 'to', title: 'PAID TO', get: seller },
|
|
1187
|
+
{ key: 's', title: 'HOW', get: (p) => p.scheme },
|
|
1188
|
+
{ key: 'tx', title: 'TX', get: (p) => shortHash(p.tx_hash, 14) },
|
|
1189
|
+
{ key: 'l', title: 'LOADED', get: (p) => (loaded.has(p.patch_id) ? c.ok(`#${loaded.get(p.patch_id)}`) : c.dim('-')), align: 'right' },
|
|
1190
|
+
{ key: 'f', title: 'FILE', get: (p) => (p.path ? c.ok('here') : c.warn(`gone — ${PROG} patch download ${p.patch_id}`)) },
|
|
1191
|
+
{ key: 't', title: 'BOUGHT', get: (p) => fmtTime(p.created_at) },
|
|
1192
|
+
], `this node has not bought anything yet — \`${PROG} patch ls\` is the catalogue, \`${PROG} use <id>\` buys and loads one`));
|
|
1193
|
+
return r.items;
|
|
1194
|
+
}
|
|
1195
|
+
export async function patchRecords(ctx, id) {
|
|
1196
|
+
const r = await new NodeClient(ctx).get(`/api/patches/${encodeURIComponent(id)}/records`);
|
|
1197
|
+
emit(ctx, r.records, (rows) => table(rows, [
|
|
1198
|
+
{ key: 't', title: 'AT', get: (x) => fmtTime(x.ts) }, { key: 'k', title: 'KIND', get: (x) => c.id(x.kind) },
|
|
1199
|
+
{ key: 'a', title: 'AUTHOR', get: (x) => shortAddr(x.author, 8) }, { key: 'h', title: 'HASH', get: (x) => shortHash(x.hash, 16) },
|
|
1200
|
+
{ key: 's', title: 'SIG/TX', get: (x) => shortHash(x.sig, 16) },
|
|
1201
|
+
], 'no ledger records for that patch'));
|
|
1202
|
+
return r.records;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* `ainize patch rm <id>` — delete a draft, after saying what goes (item 168).
|
|
1206
|
+
*
|
|
1207
|
+
* The friction was inverted twice over: the console makes you type the full id to delete a draft, while this
|
|
1208
|
+
* command did it on one keystroke — no prompt, no `--yes`, no report of what was deleted, and no undo. The draft
|
|
1209
|
+
* may be the only record of a lesson trained somewhere else, whose recipe the operator no longer has.
|
|
1210
|
+
*/
|
|
1211
|
+
export async function patchRm(ctx, id, opts = {}) {
|
|
1212
|
+
const client = new NodeClient(ctx);
|
|
1213
|
+
const d = await client.get(`/api/patches/${encodeURIComponent(id)}`).catch(() => null);
|
|
1214
|
+
if (d && d.status !== 'DRAFT') {
|
|
1215
|
+
throw new CliError(`${id} is ${d.status}, not a draft — a published knowledge cannot be deleted (its anchor is on the permanent record). Take it off sale with \`${PROG} patch retire ${id}\`, or stop serving the file with \`${PROG} patch forget ${id}\`.`);
|
|
1216
|
+
}
|
|
1217
|
+
if (d) {
|
|
1218
|
+
const a = d.anchor;
|
|
1219
|
+
info(ctx, [
|
|
1220
|
+
`${c.id(id)} · ${a.name}`,
|
|
1221
|
+
c.dim(` ${a.rows.toLocaleString('en-US')} rows · ${fmtBytes(a.size_bytes)} · ${a.model.id_M} · benchmark ${a.benchmark.schema} (${a.benchmark.queries} questions)`),
|
|
1222
|
+
c.dim(` deleting it removes the name, the benchmark, the lineage and the price from this node — nothing was ever written to the ledger, and there is no undo.`),
|
|
1223
|
+
d.has_body
|
|
1224
|
+
? c.dim(` the knowledge FILE itself stays in this node's blob store (sha256 ${shortHash(a.patch_sha256, 12)}, \`${PROG} blobs ls\`); \`${PROG} patch forget\` is what deletes that.`)
|
|
1225
|
+
: c.warn(' its body is not on this node'),
|
|
1226
|
+
].join('\n'));
|
|
1227
|
+
}
|
|
1228
|
+
await confirm(ctx, `Delete draft ${id}? [y/N]`, { yes: opts.yes });
|
|
1229
|
+
await client.delete(`/api/patches/${encodeURIComponent(id)}`);
|
|
1230
|
+
ok(ctx, `draft ${id} deleted${d ? c.dim(` (${d.anchor.rows.toLocaleString('en-US')} rows, ${fmtBytes(d.anchor.size_bytes)}; the file is still in the blob store)`) : ''}`);
|
|
1231
|
+
}
|
|
1232
|
+
/** `ainize patch forget <id>` — stop serving the knowledge file from this node (the public record is untouched). */
|
|
1233
|
+
/**
|
|
1234
|
+
* `ainize patch forget <id>` — stop serving the body from this node. The file is content-addressed, so it is also
|
|
1235
|
+
* the body of every other id trained from the same output: the node refuses and lists them, and `--all-sharing`
|
|
1236
|
+
* is the operator saying yes to the whole list (item 149).
|
|
1237
|
+
*/
|
|
1238
|
+
export async function patchForget(ctx, id, opts = {}) {
|
|
1239
|
+
let r;
|
|
1240
|
+
try {
|
|
1241
|
+
r = await new NodeClient(ctx).post(`/api/patches/${encodeURIComponent(id)}/forget`, { all_sharing: !!opts.allSharing });
|
|
1242
|
+
}
|
|
1243
|
+
catch (e) {
|
|
1244
|
+
const shared = e.details;
|
|
1245
|
+
if (!(e instanceof CliError) || !shared?.also_affects?.length)
|
|
1246
|
+
throw e;
|
|
1247
|
+
throw new CliError([
|
|
1248
|
+
e.message + ':',
|
|
1249
|
+
table(shared.also_affects, [
|
|
1250
|
+
{ key: 'id', title: 'ALSO STOPS SERVING', get: (x) => c.id(x.id) },
|
|
1251
|
+
{ key: 'name', title: 'NAME', get: (x) => x.name },
|
|
1252
|
+
{ key: 'st', title: 'STATUS', get: (x) => statusColor(x.status) },
|
|
1253
|
+
{ key: 'sales', title: 'SALES', get: (x) => String(x.sales), align: 'right' },
|
|
1254
|
+
]),
|
|
1255
|
+
c.dim(`the public record is untouched either way. To stop serving all of them: \`${PROG} patch forget ${id} --all-sharing\``),
|
|
1256
|
+
].join('\n'), 1, shared);
|
|
1257
|
+
}
|
|
1258
|
+
emit(ctx, r, (x) => c.ok('✓ ') + `forgot ${c.id(x.patch_id)} body ${c.dim(shortHash(x.sha256, 12))} — ${x.deleted_file ? 'file deleted' : 'file left in place'}, no longer served from this node`
|
|
1259
|
+
+ (x.also_affects.length ? `\n${c.warn('! ')}same body as ${x.also_affects.map((a) => a.id).join(', ')} — those are no longer served from here either` : ''));
|
|
1260
|
+
// Forgetting is not a takedown (item 148): the listing is still on the record and the gateway still charges for it.
|
|
1261
|
+
info(ctx, c.dim(` this changes nothing on the public record: ${r.patch_id} stays listed and its gateway keeps taking payments. To take it off sale for good: ${PROG} patch retire ${r.patch_id}`));
|
|
1262
|
+
return r;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* `ainize use <id>` — the one-line consumer path: check it is verified, pay automatically (x402), download,
|
|
1266
|
+
* verify the body hash and load it into this node's model. Falls back gracefully when the runtime is off.
|
|
1267
|
+
*/
|
|
1268
|
+
export async function patchUse(ctx, id, opts = {}) {
|
|
1269
|
+
const client = new NodeClient(ctx);
|
|
1270
|
+
const detail = await client.get(`/api/patches/${encodeURIComponent(id)}`);
|
|
1271
|
+
const apply = opts.apply !== false;
|
|
1272
|
+
// SUPERSEDED knowledge stays valid (point-in-time versions); it just has a newer version on the same subject.
|
|
1273
|
+
if (detail.status === 'CHALLENGED') {
|
|
1274
|
+
const ch = detail.open_challenge;
|
|
1275
|
+
throw new CliError(`${id} is CHALLENGED — a verifier disputes it, so it is not for sale until it is re-verified${ch ? `\n ${shortAddr(ch.challenger, 8)}: "${ch.reason}" (${fmtTime(ch.created_at)})` : ''}\n see the dispute: ainize patch get ${id}`);
|
|
1276
|
+
}
|
|
1277
|
+
/*
|
|
1278
|
+
* Item 263 — every non-LISTED status used to be reported as "not verified yet", so a finished, FAILED verification
|
|
1279
|
+
* read as one still in progress and a script keyed on that sentence retried a rejected bake forever. A verification
|
|
1280
|
+
* that is over says so, with what the verifiers answered, and exits 6 (the same code `teach train --wait` uses for
|
|
1281
|
+
* REJECTED) so a cron line can tell "wait" from "this will never work".
|
|
1282
|
+
*/
|
|
1283
|
+
/*
|
|
1284
|
+
* Buying something nobody has checked.
|
|
1285
|
+
*
|
|
1286
|
+
* A seller's node may allow this (`verifier.sellUnverified`), and the knowledge STAYS ANNOUNCED — it is
|
|
1287
|
+
* never relabelled LISTED, because a status claiming "verified" when nobody checked is worth less than no
|
|
1288
|
+
* status. So the risk is real and the buyer carries it, which means they have to be told what they are
|
|
1289
|
+
* carrying BEFORE the money moves rather than discovering it after: how many attestations exist, that a
|
|
1290
|
+
* benchmark score in the anchor is the SELLER'S OWN claim until a verifier reproduces it, and that nobody
|
|
1291
|
+
* independent has checked whether loading it damages unrelated answers.
|
|
1292
|
+
*
|
|
1293
|
+
* --yes answers it, as everywhere else. The warning still prints: an unattended script should leave a
|
|
1294
|
+
* record of what it accepted.
|
|
1295
|
+
*/
|
|
1296
|
+
if (!detail.quorum_ok && detail.status !== 'REJECTED') {
|
|
1297
|
+
const n = detail.passed ?? 0;
|
|
1298
|
+
warn(ctx, `${c.id(id)} is ${detail.status}, NOT verified — ${n}/${detail.quorum} independent attestation${n === 1 ? '' : 's'}.`);
|
|
1299
|
+
info(ctx, c.dim(` · its benchmark score is the seller's own claim until a verifier reproduces it`));
|
|
1300
|
+
info(ctx, c.dim(` · nobody independent has checked whether loading it damages unrelated answers`));
|
|
1301
|
+
info(ctx, c.dim(` · watch it instead: ${PROG} patch get ${id} — quorum is ${detail.quorum}`));
|
|
1302
|
+
await confirm(ctx, `Buy ${id} unverified, at your own risk? [y/N]`, { yes: opts.yes });
|
|
1303
|
+
}
|
|
1304
|
+
if (detail.status === 'REJECTED') {
|
|
1305
|
+
const fails = detail.attestations.filter((x) => !x.passed).length;
|
|
1306
|
+
throw new CliError(`${id} FAILED verification — ${detail.passed}/${detail.quorum} passed, ${fails} verifier${fails === 1 ? '' : 's'} answered FAIL. It is not for sale and retrying will not change it: the network measured this build and rejected it.\n`
|
|
1307
|
+
+ ` what they answered: ${PROG} patch get ${id}\n`
|
|
1308
|
+
+ ` ${detail.superseded_by.length ? `a newer build of the same subject: ${detail.superseded_by.join(', ')}` : 'wait for its publisher to bake a new one'}`, 6);
|
|
1309
|
+
}
|
|
1310
|
+
if (detail.status === 'RETIRED') {
|
|
1311
|
+
throw new CliError(`${id} was withdrawn by its publisher${detail.retire_reason ? ` ("${detail.retire_reason}")` : ''} — off sale for good; everyone who already bought it keeps their copy.`, 6);
|
|
1312
|
+
}
|
|
1313
|
+
if (!detail.quorum_ok || !['LISTED', 'SUPERSEDED'].includes(detail.status))
|
|
1314
|
+
throw new CliError(`${id} is ${detail.status} (verification ${detail.passed}/${detail.quorum}) — not verified yet, so it cannot be bought here; the verifiers usually answer within a few minutes. Watch it with \`${PROG} patch get ${id}\``);
|
|
1315
|
+
/*
|
|
1316
|
+
* Item 236 — this was printed through ok(): a green ✓ one line above "✓ bought", which is the shape of good news.
|
|
1317
|
+
* A script assembling a set from ids seen last week bought and stacked retired versions and nothing stopped it.
|
|
1318
|
+
* It is a warning, and it has to be answered: `--allow-superseded` (or `--yes`) is the deliberate answer.
|
|
1319
|
+
*/
|
|
1320
|
+
if (detail.status === 'SUPERSEDED' && detail.superseded_by?.length) {
|
|
1321
|
+
warn(ctx, `${c.id(id)} has been superseded: a newer version of the same subject exists → ${detail.superseded_by.map((x) => c.id(x)).join(', ')}`);
|
|
1322
|
+
if (!opts.allowSuperseded) {
|
|
1323
|
+
await confirm(ctx, `Buy the older ${id} anyway? [y/N]`, { yes: opts.yes });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (detail.has_body && (detail.purchased || detail.owned)) {
|
|
1327
|
+
ok(ctx, `${c.id(id)} is already on this node ${detail.owned ? '(you published it)' : '(purchased)'}`);
|
|
1328
|
+
// item 220: the document says which of the two happened, and whether it ended up loaded
|
|
1329
|
+
// §8.7 — `use` means the knowledge WORKS afterwards, so an add-on is loaded with the stack it was trained on
|
|
1330
|
+
// top of; without this, `ainize use <child>` on a held child failed with `needs_base` and left nothing loaded.
|
|
1331
|
+
if (apply) {
|
|
1332
|
+
await patchApply(ctx, id, { withBase: true });
|
|
1333
|
+
}
|
|
1334
|
+
if (!ctx.json)
|
|
1335
|
+
ok(ctx, c.dim(`try it: ${PROG} chat ${id} "your question"`));
|
|
1336
|
+
return emitStep(ctx, !!opts.batched, { patch_id: id, already: true, owned: !!detail.owned, purchased: !!detail.purchased, applied: apply, has_body: true }, () => '');
|
|
1337
|
+
}
|
|
1338
|
+
// A knowledge already paid for whose body this node no longer holds is COLLECTED, not bought again (item 273).
|
|
1339
|
+
if (detail.purchased && !detail.has_body) {
|
|
1340
|
+
info(ctx, c.dim(`${id} was already paid for on this node but its body is not here — collecting it again, free`));
|
|
1341
|
+
const back = await patchDownload(ctx, id);
|
|
1342
|
+
if (apply)
|
|
1343
|
+
await patchApply(ctx, id, { withBase: true });
|
|
1344
|
+
return back;
|
|
1345
|
+
}
|
|
1346
|
+
const r = await patchBuy(ctx, id, { ...opts, apply });
|
|
1347
|
+
if (!ctx.json)
|
|
1348
|
+
ok(ctx, c.dim(apply ? `loaded into the model — try: ainize chat ${id} "your question"` : `downloaded — load with: ainize patch apply ${id}`));
|
|
1349
|
+
return r;
|
|
1350
|
+
}
|
|
1351
|
+
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
|
|
1352
|
+
/** What the draft is built from — pure, so the unit test can pin the mapping without a node. */
|
|
1353
|
+
export function draftFromRecipe(file, r, a = {}) {
|
|
1354
|
+
const lesson = r.lesson ?? {};
|
|
1355
|
+
const facts = lesson.facts ?? r.facts ?? [];
|
|
1356
|
+
const first = facts[0]?.prompt ?? null;
|
|
1357
|
+
const base = basename(file).replace(/\.npz$/i, '').replace(/^lesson-/, '');
|
|
1358
|
+
const id = a.id ?? (lesson.draft_id ? lesson.draft_id : `taught-${slugify(base) || 'lesson'}`);
|
|
1359
|
+
const name = a.name ?? (lesson.name?.trim() || (first ? `Lesson: ${first.slice(0, 60)}` : `Lesson ${base}`));
|
|
1360
|
+
const model_id = a.model ?? r.model_id ?? lesson.model_id ?? (typeof r.model?.id_M === 'string' ? r.model.id_M : undefined);
|
|
1361
|
+
const samples = (r.benchmark?.samples ?? r.benchmark_samples ?? facts.map((f) => ({ prompt: `Q: ${f.prompt}\nA: `, expect: f.answer })));
|
|
1362
|
+
const seen = new Set();
|
|
1363
|
+
const uniq = samples.filter((x) => { const k = `${x.prompt}\u0000${x.expect}`; if (seen.has(k) || !x.prompt)
|
|
1364
|
+
return false; seen.add(k); return true; });
|
|
1365
|
+
const benchmark = {
|
|
1366
|
+
schema: r.benchmark?.schema ?? `taught/${slugify(base) || 'lesson'}`, queries: r.benchmark?.queries ?? uniq.length,
|
|
1367
|
+
format: r.benchmark?.format ?? ['template', 'chat'], collateral_bound_nat: r.benchmark?.collateral_bound_nat ?? 0.08, samples: uniq,
|
|
1368
|
+
};
|
|
1369
|
+
const src = lesson.node?.url ? ` Imported from ${lesson.node.name ? `${lesson.node.name} (${lesson.node.url})` : lesson.node.url}${lesson.job_id ? `, lesson ${lesson.job_id}` : ''}.` : '';
|
|
1370
|
+
const description = a.description ?? `Taught lesson: ${facts.map((f) => `${f.prompt} → ${f.answer}`).join(' · ').slice(0, 400)}.${src}`.trim();
|
|
1371
|
+
const contributors = lesson.contributor && ADDR_RE.test(lesson.contributor.address)
|
|
1372
|
+
? [{ address: lesson.contributor.address, share: 0, role: 'data_provider', proof: 'declared', ...(lesson.contributor.name ? { name: lesson.contributor.name.slice(0, 40) } : {}) }]
|
|
1373
|
+
: undefined;
|
|
1374
|
+
return { id, name, model_id, benchmark, description, contributors, parents: lesson.builds_on_context ? (lesson.context_patch_ids ?? []) : [], first_prompt: first };
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Import a downloaded lesson (`lesson-<slug>.npz` + `recipe.json`) as a PRIVATE draft on this node: the file stays where it is
|
|
1378
|
+
* (`keepInPlace`), the benchmark comes from the recipe, nothing is announced and no ledger record is written.
|
|
1379
|
+
* The data provider is kept on the draft as credit only (share 0) and the origin is `teach`, so a later `patch announce`
|
|
1380
|
+
* still shows "Taught by …" — raise the share with `PATCH /api/patches/:id` / the web form before announcing if you want to pay them.
|
|
1381
|
+
*/
|
|
1382
|
+
export async function patchImport(ctx, a) {
|
|
1383
|
+
const file = resolve(a.file);
|
|
1384
|
+
if (!existsSync(file))
|
|
1385
|
+
throw new CliError(`file not found: ${file}`);
|
|
1386
|
+
if (!file.endsWith('.npz'))
|
|
1387
|
+
throw new CliError('a lesson body is a .npz (addrs/before/after arrays)');
|
|
1388
|
+
const recipePath = resolve(a.recipe);
|
|
1389
|
+
if (!existsSync(recipePath))
|
|
1390
|
+
throw new CliError(`recipe not found: ${recipePath} (download recipe.json next to the lesson file)`);
|
|
1391
|
+
let recipe;
|
|
1392
|
+
try {
|
|
1393
|
+
recipe = JSON.parse(readFileSync(recipePath, 'utf8'));
|
|
1394
|
+
}
|
|
1395
|
+
catch {
|
|
1396
|
+
throw new CliError(`${recipePath} is not valid JSON`);
|
|
1397
|
+
}
|
|
1398
|
+
const d = draftFromRecipe(file, recipe, a);
|
|
1399
|
+
if (!d.model_id)
|
|
1400
|
+
throw new CliError('the recipe names no model — pass --model <id_M> (must be the exact model this node serves)');
|
|
1401
|
+
const sha256 = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
1402
|
+
const expect = recipe.lesson?.sha256;
|
|
1403
|
+
const sha_matches = expect ? expect.toLowerCase() === sha256 : null;
|
|
1404
|
+
if (sha_matches === false)
|
|
1405
|
+
throw new CliError(`sha256 mismatch: file is ${sha256}, recipe.json expects ${expect} — download the lesson again`);
|
|
1406
|
+
const client = new NodeClient(ctx);
|
|
1407
|
+
let model_matches = null;
|
|
1408
|
+
try {
|
|
1409
|
+
const info = await client.get('/api/info', { auth: false });
|
|
1410
|
+
model_matches = info.model ? info.model === d.model_id : null;
|
|
1411
|
+
}
|
|
1412
|
+
catch { /* reported by the POST below */ }
|
|
1413
|
+
if (model_matches === false)
|
|
1414
|
+
warnLine(ctx, `this node serves a different model than the lesson was trained on (node: see /api/info, lesson: ${d.model_id}) — the lesson will not fire`);
|
|
1415
|
+
/**
|
|
1416
|
+
* A lesson trained on top of somebody's knowledge carries that knowledge's id in its recipe, and the import used
|
|
1417
|
+
* to drop the link with a warning whenever this node had not heard of the base yet (item 175) — turning a
|
|
1418
|
+
* derivative into a root, with no credit and no royalty, on a line most people scroll past. It refuses now: the
|
|
1419
|
+
* base has to reach this node first (`ainize peers add <url>` is one gossip round), and `--drop-lineage` is the
|
|
1420
|
+
* explicit way to say "import it as a root anyway".
|
|
1421
|
+
*/
|
|
1422
|
+
const parents = [];
|
|
1423
|
+
const unknownParents = [];
|
|
1424
|
+
for (const pid of d.parents) {
|
|
1425
|
+
try {
|
|
1426
|
+
await client.get(`/api/patches/${encodeURIComponent(pid)}`, { auth: false });
|
|
1427
|
+
parents.push(pid);
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
unknownParents.push(pid);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (unknownParents.length && !a.dropLineage) {
|
|
1434
|
+
throw new CliError(`this lesson was trained on top of ${unknownParents.join(', ')}, and ${unknownParents.length > 1 ? 'those are' : 'that is'} not on this node — importing it now would record it as a root, with no credit to ${unknownParents.length > 1 ? 'their creators' : 'its creator'} and no royalty on any sale.\n add the node that publishes it: ${PROG} peers add <its url> (the record arrives within about 10 s)\n or import it as a root anyway: ${PROG} patch import ${a.file} --recipe ${a.recipe} --drop-lineage`);
|
|
1435
|
+
}
|
|
1436
|
+
if (unknownParents.length)
|
|
1437
|
+
warnLine(ctx, `--drop-lineage: imported WITHOUT the link to ${unknownParents.join(', ')} — this draft claims no base, so nobody is credited if you publish it`);
|
|
1438
|
+
const r = await client.post('/api/patches', {
|
|
1439
|
+
id: d.id, name: d.name, model_id: d.model_id, benchmark: JSON.stringify(d.benchmark), description: d.description, price: a.price, license: a.license,
|
|
1440
|
+
parents: parents.join(',') || undefined, path: file, contributors: d.contributors ? JSON.stringify(d.contributors) : undefined,
|
|
1441
|
+
// An import is a PRIVATE draft of a lesson someone else trained, not a publish: the model mismatch is stated in
|
|
1442
|
+
// words above (it is the whole point of the warning) rather than refused the way `publish` refuses it (item 154).
|
|
1443
|
+
force: true,
|
|
1444
|
+
});
|
|
1445
|
+
let anchor = r.anchor;
|
|
1446
|
+
try {
|
|
1447
|
+
anchor = (await client.patch(`/api/patches/${encodeURIComponent(anchor.id)}`, { origin: 'teach' })).anchor;
|
|
1448
|
+
}
|
|
1449
|
+
catch { /* older node without origin — the draft is still usable */ }
|
|
1450
|
+
const out = { anchor, sha256, sha_matches, model_matches, first_prompt: d.first_prompt };
|
|
1451
|
+
emit(ctx, out, (x) => [
|
|
1452
|
+
c.ok('✓ ') + `imported ${c.id(x.anchor.id)} as a private draft (${x.anchor.rows.toLocaleString('en-US')} rows, sha256 ${shortHash(x.sha256, 16)}${x.sha_matches ? c.ok(' matches recipe') : ''})`,
|
|
1453
|
+
c.dim(` no ledger record was written; the file stays at ${file}`),
|
|
1454
|
+
c.dim(` load it: ainize patch apply ${x.anchor.id}`),
|
|
1455
|
+
c.dim(` try it: ainize chat ${x.anchor.id} ${JSON.stringify(x.first_prompt ?? 'your question')}`),
|
|
1456
|
+
c.dim(` unload: ainize patch remove ${x.anchor.id}`),
|
|
1457
|
+
].join('\n'));
|
|
1458
|
+
return out;
|
|
1459
|
+
}
|
|
1460
|
+
function warnLine(ctx, msg) { if (!ctx.quiet && !ctx.json)
|
|
1461
|
+
process.stderr.write(c.warn('warning: ') + msg + '\n'); }
|
|
1462
|
+
/**
|
|
1463
|
+
* One line to use knowledge somebody else published, given only its name.
|
|
1464
|
+
*
|
|
1465
|
+
* This replaces a three-command sequence in which the operator carried an id between two machines by hand:
|
|
1466
|
+
*
|
|
1467
|
+
* ainize patch ls --node http://their-node:3402 --status LISTED -q "<topic>"
|
|
1468
|
+
* ainize login && ainize use <id>
|
|
1469
|
+
*
|
|
1470
|
+
* WHAT THE NAME SUPPLIES is exactly what those commands supplied by hand: `ainize.node` is the `--node` of the
|
|
1471
|
+
* first line, `ainize.patch` is the `<id>` of the third. Nothing else changes, and in particular THE BUYING
|
|
1472
|
+
* STILL HAPPENS ON YOUR OWN NODE. `--node` in `patch ls` points at the seller because you are reading their
|
|
1473
|
+
* catalogue; `use` has always run against your node, because your node is what pays, downloads and applies.
|
|
1474
|
+
* Resolving a name must not quietly move that: a command that logged you into someone else's node and applied
|
|
1475
|
+
* knowledge there would be a different operation wearing this one's name.
|
|
1476
|
+
*
|
|
1477
|
+
* So the seller's endpoint is used for the one thing it is for — telling your node where to fetch from, as a
|
|
1478
|
+
* peer — and the rest of the flow is the local one you would have typed.
|
|
1479
|
+
*/
|
|
1480
|
+
export async function patchByName(ctx, name, a = {}) {
|
|
1481
|
+
if (!looksLikeEnsName(name)) {
|
|
1482
|
+
throw new CliError(`${name} is not a name this command can resolve. Give a name like vaults.defi.engram.eth, ` +
|
|
1483
|
+
`or use \`${PROG} patch get <id>\` / \`${PROG} use <id>\` for a knowledge id.`);
|
|
1484
|
+
}
|
|
1485
|
+
let r;
|
|
1486
|
+
try {
|
|
1487
|
+
r = await resolveName(name, { rpc: a.rpc, registry: a.registry, namesFile: a.namesFile, config: ctx.cfg?.ens ?? null, home: ctx.home });
|
|
1488
|
+
}
|
|
1489
|
+
catch (e) {
|
|
1490
|
+
throw new CliError(e.message);
|
|
1491
|
+
}
|
|
1492
|
+
// --json prints the resolution as data; a terminal gets one line naming the source, because "where did this
|
|
1493
|
+
// id come from" is the question a name makes easy to stop asking.
|
|
1494
|
+
emit(ctx, { resolved: { name: r.name, node: r.node, patch: r.patch, source: r.source, where: r.where } }, (d) => `${c.bold(d.resolved.name)} → ${d.resolved.patch} on ${d.resolved.node} ${c.dim(`(${d.resolved.source}: ${d.resolved.where})`)}`);
|
|
1495
|
+
if (a.resolveOnly)
|
|
1496
|
+
return r;
|
|
1497
|
+
// The seller's endpoint is for FETCHING, not for logging into. Peering is idempotent on the node side and it
|
|
1498
|
+
// is what lets the blob come over p2p rather than requiring the anchor to already be local.
|
|
1499
|
+
if (!a.noPeer && r.node.replace(/\/+$/, '') !== ctx.nodeUrl.replace(/\/+$/, '')) {
|
|
1500
|
+
const client = new NodeClient(ctx);
|
|
1501
|
+
await client.post('/api/peers', { endpoint: r.node.replace(/\/+$/, '') }).catch((e) => {
|
|
1502
|
+
// A peer that cannot be added is not fatal: the knowledge may already be visible on a shared ledger.
|
|
1503
|
+
// Say so rather than failing, and let the buy report the real problem if there is one.
|
|
1504
|
+
warn(ctx, `could not add ${r.node} as a peer (${e.message}) — continuing, in case this knowledge is already on your ledger`);
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
// `use` is an operator action on YOUR node, so this is your login, not theirs.
|
|
1508
|
+
if (!ctx.token) {
|
|
1509
|
+
const r2 = await auth.login(ctx, {});
|
|
1510
|
+
ctx = { ...ctx, token: r2.token };
|
|
1511
|
+
}
|
|
1512
|
+
try {
|
|
1513
|
+
return await patchUse(ctx, r.patch, a);
|
|
1514
|
+
}
|
|
1515
|
+
catch (e) {
|
|
1516
|
+
const msg = e.message;
|
|
1517
|
+
if (/not found/i.test(msg)) {
|
|
1518
|
+
throw new CliError(`${r.name} points at ${r.patch} on ${r.node}, but your node does not know that knowledge.\n` +
|
|
1519
|
+
` • if it is a DRAFT it is private to its publisher and cannot be bought\n` +
|
|
1520
|
+
` • if it is announced on a different ledger than yours, your node can never read its anchor\n` +
|
|
1521
|
+
` • if ${r.node} is unreachable, the peer add above will have said so\n` +
|
|
1522
|
+
`Check with: ${PROG} patch ls --node ${r.node} --status LISTED`, 1);
|
|
1523
|
+
}
|
|
1524
|
+
throw e;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
//# sourceMappingURL=patch.js.map
|