@skillcdn/console 0.1.24 → 0.3.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/README.md +6 -5
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +64 -65
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +4 -28
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +7 -261
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +6 -6
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/pack.d.ts +46 -0
- package/dist/cli/pack.d.ts.map +1 -0
- package/dist/cli/pack.js +250 -0
- package/dist/cli/pack.js.map +1 -0
- package/dist/compat.d.ts +1 -1
- package/dist/compat.js +1 -1
- package/dist/components/decision-list.d.ts +13 -1
- package/dist/components/decision-list.d.ts.map +1 -1
- package/dist/components/decision-list.js +74 -29
- package/dist/components/decision-list.js.map +1 -1
- package/dist/components/paged.d.ts +24 -0
- package/dist/components/paged.d.ts.map +1 -1
- package/dist/components/paged.js +40 -4
- package/dist/components/paged.js.map +1 -1
- package/dist/components/skills.d.ts.map +1 -1
- package/dist/components/skills.js +8 -4
- package/dist/components/skills.js.map +1 -1
- package/dist/components/ui.d.ts +2 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +7 -2
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +112 -5
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +2 -2
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +27 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +13 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +15 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +15 -0
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +6 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +13 -7
- package/dist/router.js.map +1 -1
- package/dist/schemas.d.ts +321 -159
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +61 -154
- package/dist/schemas.js.map +1 -1
- package/docs/specs/cli.md +3 -3
- package/docs/specs/rest.md +5 -13
- package/package.json +2 -1
- package/skills/deploying-a-console/SKILL.md +2 -2
- package/skills/working-the-board/SKILL.md +3 -3
- package/src/api.ts +2 -7
- package/src/cli/cli.ts +92 -76
- package/src/cli/format.ts +10 -328
- package/src/cli/help.ts +7 -6
- package/src/cli/pack.ts +356 -0
- package/src/compat.ts +1 -1
- package/src/components/decision-list.tsx +227 -94
- package/src/components/paged.tsx +72 -3
- package/src/components/skills.tsx +51 -33
- package/src/components/ui.tsx +16 -3
- package/src/console.tsx +3 -0
- package/src/data.ts +32 -2
- package/src/i18n/en.ts +18 -0
- package/src/i18n/ko.ts +17 -0
- package/src/index.ts +9 -1
- package/src/router.ts +20 -9
- package/src/schemas.ts +76 -180
- package/src/styles/console.css +112 -5
package/src/cli/pack.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BrowseResult,
|
|
3
|
+
browseBody,
|
|
4
|
+
browseResultOf,
|
|
5
|
+
catalogStateOf,
|
|
6
|
+
type DirectoryResult,
|
|
7
|
+
directoryBody,
|
|
8
|
+
directoryResultOf,
|
|
9
|
+
type FileResult,
|
|
10
|
+
type FindItem,
|
|
11
|
+
type FindResult,
|
|
12
|
+
fileBody,
|
|
13
|
+
fileResultOf,
|
|
14
|
+
findResultOf,
|
|
15
|
+
joinSkillPages,
|
|
16
|
+
type MountSummary,
|
|
17
|
+
mountSummaryOf,
|
|
18
|
+
parseAddress,
|
|
19
|
+
parseRepoPath,
|
|
20
|
+
parseSkillUri,
|
|
21
|
+
type RestBrowse,
|
|
22
|
+
type RestFile,
|
|
23
|
+
type RestMount,
|
|
24
|
+
type RestSkill,
|
|
25
|
+
renderBrowseResult,
|
|
26
|
+
renderDirectoryResult,
|
|
27
|
+
renderFileResult,
|
|
28
|
+
renderFindResult,
|
|
29
|
+
renderInstructions,
|
|
30
|
+
renderSkillResult,
|
|
31
|
+
type SkillResult,
|
|
32
|
+
skillOutcome,
|
|
33
|
+
skillResultOf,
|
|
34
|
+
type ToolWords,
|
|
35
|
+
} from "@skillcdn/core";
|
|
36
|
+
import type {
|
|
37
|
+
RestSkillBrowse,
|
|
38
|
+
RestSkillFile,
|
|
39
|
+
RestSkillFind,
|
|
40
|
+
RestSkillLoad,
|
|
41
|
+
RestSkills,
|
|
42
|
+
} from "../schemas.js";
|
|
43
|
+
import type { SkillsStatus } from "../vocabulary.js";
|
|
44
|
+
|
|
45
|
+
// The pack of the project, as the command prints it (ADR-0028, ADR-0031): the console answers
|
|
46
|
+
// what the pack's REST API answered, in the shapes of @skillcdn/core, and the command renders
|
|
47
|
+
// it with the functions a deployment's own tools render for a model, naming the reads as its
|
|
48
|
+
// own commands, so that an agent reads here what it would read over MCP, or with the skillcdn
|
|
49
|
+
// command. The pages a read came in are joined first: a skill, a folder and a file arrive whole.
|
|
50
|
+
|
|
51
|
+
/** How the results name the four reads, so that an agent is told the command and not a tool. */
|
|
52
|
+
export const CONSOLE_WORDS: ToolWords = {
|
|
53
|
+
browse: "console skills <folder>",
|
|
54
|
+
search: "console skills --search",
|
|
55
|
+
load: "console skill",
|
|
56
|
+
read: "console skill --file",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Where the reads of a pack are rendered at: what the listing said the address serves. */
|
|
60
|
+
export type PackSummary = MountSummary;
|
|
61
|
+
|
|
62
|
+
export const NO_ADDRESS = "No skills address: neither this project nor the console names one.";
|
|
63
|
+
|
|
64
|
+
/** The words of a read that did not happen, by status, with what to do about it. */
|
|
65
|
+
const SKILLS_WORDS: Readonly<Record<Exclude<SkillsStatus, "none" | "ready">, string>> = {
|
|
66
|
+
indexing: "The pack is still being indexed; ask again in a moment.",
|
|
67
|
+
failed:
|
|
68
|
+
"The pack could not be indexed, or this is a file the pack will not carry: too large, or not text.",
|
|
69
|
+
not_found:
|
|
70
|
+
"Nothing is served to the console at this address or path: it is not there, or the pack is private and the project holds no pack token for it (console project pack-token <key> --stdin, for an owner).",
|
|
71
|
+
token_invalid:
|
|
72
|
+
"The pack token the project holds no longer works at the deployment: an owner sets a new one (console project pack-token <key> --stdin).",
|
|
73
|
+
unavailable: "The pack could not be reached; ask again later.",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type Envelope = Pick<RestSkills, "address" | "source" | "page" | "status" | "refusal">;
|
|
77
|
+
|
|
78
|
+
/** The first line of an answer about the pack: what was read, where the pack is, and how it went. */
|
|
79
|
+
function head(what: string, answer: Envelope): string {
|
|
80
|
+
const where =
|
|
81
|
+
answer.source === null ? "read from the console's machine" : `served by ${answer.source}`;
|
|
82
|
+
return `${what} at ${answer.address}, ${where}: ${answer.status}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A read that did not happen: the head, the words of its status, and the pack's own refusal when it gave one. */
|
|
86
|
+
function refused(what: string, answer: Envelope): string {
|
|
87
|
+
const lines = [head(what, answer)];
|
|
88
|
+
if (answer.status !== "none" && answer.status !== "ready") {
|
|
89
|
+
lines.push(SKILLS_WORDS[answer.status]);
|
|
90
|
+
}
|
|
91
|
+
if (answer.refusal !== null) {
|
|
92
|
+
lines.push(answer.refusal.message);
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What the listing said the address serves, for the reads to be rendered at; nothing when it did not answer. */
|
|
98
|
+
export function summaryOf(listing: RestSkills): PackSummary | undefined {
|
|
99
|
+
return listing.mount === null ? undefined : mountSummaryOf(listing.mount);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every skill and document the address serves, as one listing: what `search_repo` without words answers. */
|
|
103
|
+
function listingOf(mount: RestMount, summary: PackSummary): FindResult | undefined {
|
|
104
|
+
if (mount.index.status !== "ready") {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const { index } = mount;
|
|
108
|
+
const items: FindItem[] = [];
|
|
109
|
+
for (const skill of index.skills) {
|
|
110
|
+
const directory = parseRepoPath(skill.directory);
|
|
111
|
+
if (!directory.ok) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const path = parseRepoPath(
|
|
115
|
+
skill.path ?? (skill.directory === "" ? "SKILL.md" : `${skill.directory}/SKILL.md`),
|
|
116
|
+
);
|
|
117
|
+
items.push({
|
|
118
|
+
kind: "skill",
|
|
119
|
+
...(path.ok ? { path: path.value } : {}),
|
|
120
|
+
name: skill.name,
|
|
121
|
+
directory: directory.value,
|
|
122
|
+
description: skill.description,
|
|
123
|
+
files: [],
|
|
124
|
+
moreFiles: 0,
|
|
125
|
+
// For people, and not rendered for a model.
|
|
126
|
+
translations: {},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
for (const document of index.documents) {
|
|
130
|
+
const path = parseRepoPath(document.path);
|
|
131
|
+
if (path.ok) {
|
|
132
|
+
items.push({
|
|
133
|
+
kind: "document",
|
|
134
|
+
path: path.value,
|
|
135
|
+
title: document.title ?? undefined,
|
|
136
|
+
summary: document.summary ?? undefined,
|
|
137
|
+
skillDirectory: undefined,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const state = catalogStateOf(mount);
|
|
142
|
+
return {
|
|
143
|
+
mount: summary,
|
|
144
|
+
query: undefined,
|
|
145
|
+
items,
|
|
146
|
+
// The counts stand in the instructions above; without totals here, nothing suggests a cursor.
|
|
147
|
+
totals: undefined,
|
|
148
|
+
diagnostics: state.status === "ready" ? state.catalog.diagnostics : [],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The pack: where it is and how it answered; what a client is told on connect, as the
|
|
154
|
+
* deployment tells one; every skill and document; where a person connects an agent of its own;
|
|
155
|
+
* and where the console's own skills are on this machine.
|
|
156
|
+
*/
|
|
157
|
+
export function formatSkills(skills: RestSkills, ownSkills?: string): string {
|
|
158
|
+
const own =
|
|
159
|
+
ownSkills === undefined
|
|
160
|
+
? []
|
|
161
|
+
: ["", `The console's own skills, for working the board: ${ownSkills}`];
|
|
162
|
+
if (skills.status === "none" || skills.address === null) {
|
|
163
|
+
return [NO_ADDRESS, ...own].join("\n");
|
|
164
|
+
}
|
|
165
|
+
const summary = summaryOf(skills);
|
|
166
|
+
if (skills.status !== "ready" || skills.mount === null || summary === undefined) {
|
|
167
|
+
return [refused("skills", skills), ...own].join("\n");
|
|
168
|
+
}
|
|
169
|
+
const lines = [
|
|
170
|
+
head("skills", skills),
|
|
171
|
+
"",
|
|
172
|
+
renderInstructions(catalogStateOf(skills.mount), CONSOLE_WORDS),
|
|
173
|
+
];
|
|
174
|
+
const listing = listingOf(skills.mount, summary);
|
|
175
|
+
if (listing !== undefined) {
|
|
176
|
+
lines.push("", renderFindResult(listing, CONSOLE_WORDS));
|
|
177
|
+
}
|
|
178
|
+
if (skills.page !== null) {
|
|
179
|
+
lines.push(
|
|
180
|
+
"",
|
|
181
|
+
`A host with a SkillCDN connection of its own loads a skill by its URI, which a person makes at ${skills.page}; through the console, ${CONSOLE_WORDS.load} takes the URI too. --json adds the page of each skill, for a person.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
lines.push(...own);
|
|
185
|
+
return lines.join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A skill's path as given, or taken out of the URI the listing names it by
|
|
190
|
+
* (`skill://<host>/<owner>/<name>/<path>`); a URI of another pack would read this pack's skill
|
|
191
|
+
* at the same path, or nothing, so it is said instead.
|
|
192
|
+
*/
|
|
193
|
+
export function skillPathIn(
|
|
194
|
+
given: string,
|
|
195
|
+
packAddress: string | null,
|
|
196
|
+
): { readonly path: string } | { readonly error: string } {
|
|
197
|
+
const uri = parseSkillUri(given);
|
|
198
|
+
if (uri === undefined) {
|
|
199
|
+
return given.startsWith("skill://")
|
|
200
|
+
? {
|
|
201
|
+
error: `Not a skill's URI the console knows: ${given}. Say the path of its SKILL.md from the repository's root, or the URI console skills lists.`,
|
|
202
|
+
}
|
|
203
|
+
: { path: given };
|
|
204
|
+
}
|
|
205
|
+
const pack = packAddress === null ? undefined : parseAddress(packAddress);
|
|
206
|
+
if (
|
|
207
|
+
pack?.ok === true &&
|
|
208
|
+
(uri.address.host !== pack.value.host ||
|
|
209
|
+
uri.address.owner !== pack.value.owner ||
|
|
210
|
+
uri.address.repo !== pack.value.repo)
|
|
211
|
+
) {
|
|
212
|
+
return {
|
|
213
|
+
error: `That URI is of another pack, /${uri.address.host}/${uri.address.owner}/${uri.address.repo}; this project's pack is at ${packAddress}. Say the path of the SKILL.md, or a URI console skills lists.`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { path: uri.path };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The pages of a skill, as the console answered them, as the one skill they were cut from. */
|
|
220
|
+
export function joinSkill(
|
|
221
|
+
pages: readonly RestSkillLoad[],
|
|
222
|
+
summary: PackSummary,
|
|
223
|
+
): SkillResult | undefined {
|
|
224
|
+
return joinSkillPages(
|
|
225
|
+
pages.flatMap((page) =>
|
|
226
|
+
page.read?.status === "ready" ? (skillResultOf(page.read, summary) ?? []) : [],
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The skill whole, as the REST API answers one, for `--json`. */
|
|
232
|
+
export function bodyOfSkill(skill: SkillResult): RestSkill {
|
|
233
|
+
const outcome = skillOutcome({ status: "ready", lookup: { kind: "found", skill } });
|
|
234
|
+
if ("failure" in outcome) {
|
|
235
|
+
throw new Error("a skill that was found has a body");
|
|
236
|
+
}
|
|
237
|
+
return outcome.body;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One skill as `load_skill` lays it out for a model: the rules, the body, the included files, its files and references. */
|
|
241
|
+
export function formatSkillLoad(loaded: RestSkillLoad, whole: SkillResult | undefined): string {
|
|
242
|
+
if (loaded.status === "none" || loaded.address === null) {
|
|
243
|
+
return NO_ADDRESS;
|
|
244
|
+
}
|
|
245
|
+
if (loaded.status !== "ready") {
|
|
246
|
+
return refused("skill", loaded);
|
|
247
|
+
}
|
|
248
|
+
if (whole === undefined) {
|
|
249
|
+
return `${head("skill", loaded)}\nNothing was read.`;
|
|
250
|
+
}
|
|
251
|
+
return renderSkillResult(whole, CONSOLE_WORDS);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The pages of a folder, as the console answered them, as the one listing they were cut from. */
|
|
255
|
+
export function joinFolder(
|
|
256
|
+
pages: readonly RestSkillBrowse[],
|
|
257
|
+
summary: PackSummary,
|
|
258
|
+
): BrowseResult | undefined {
|
|
259
|
+
const results = pages.flatMap((page) =>
|
|
260
|
+
page.read?.status === "ready" ? (browseResultOf(page.read, summary) ?? []) : [],
|
|
261
|
+
);
|
|
262
|
+
const first = results[0];
|
|
263
|
+
const last = results[results.length - 1];
|
|
264
|
+
if (first === undefined || last === undefined) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
...first,
|
|
269
|
+
mount: last.mount,
|
|
270
|
+
entries: results.flatMap((result) => result.entries),
|
|
271
|
+
nextCursor: last.nextCursor,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A folder whole, as the REST API answers one page, for `--json`. */
|
|
276
|
+
export const bodyOfFolder = (result: BrowseResult): RestBrowse =>
|
|
277
|
+
browseBody({ status: "ready", result });
|
|
278
|
+
|
|
279
|
+
/** What a folder holds, as `browse_repo` lays it out for a model. */
|
|
280
|
+
export function formatSkillBrowse(
|
|
281
|
+
browsed: RestSkillBrowse,
|
|
282
|
+
whole: BrowseResult | undefined,
|
|
283
|
+
): string {
|
|
284
|
+
if (browsed.status === "none" || browsed.address === null) {
|
|
285
|
+
return NO_ADDRESS;
|
|
286
|
+
}
|
|
287
|
+
if (browsed.status !== "ready") {
|
|
288
|
+
return refused("folder", browsed);
|
|
289
|
+
}
|
|
290
|
+
if (whole === undefined) {
|
|
291
|
+
return `${head("folder", browsed)}\nNothing was read.`;
|
|
292
|
+
}
|
|
293
|
+
return renderBrowseResult(whole, CONSOLE_WORDS);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** What words found, as `search_repo` lays it out for a model, one page of it. */
|
|
297
|
+
export function formatSkillFind(found: RestSkillFind, summary: PackSummary | undefined): string {
|
|
298
|
+
if (found.status === "none" || found.address === null) {
|
|
299
|
+
return NO_ADDRESS;
|
|
300
|
+
}
|
|
301
|
+
if (found.status !== "ready" || found.read?.status !== "ready" || summary === undefined) {
|
|
302
|
+
return refused("search", found);
|
|
303
|
+
}
|
|
304
|
+
return renderFindResult(findResultOf(found.read, summary), CONSOLE_WORDS);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The pages of a file, as the console answered them, as the file whole; or the folder the path named. */
|
|
308
|
+
export function joinFile(
|
|
309
|
+
pages: readonly RestSkillFile[],
|
|
310
|
+
summary: PackSummary,
|
|
311
|
+
): FileResult | DirectoryResult | undefined {
|
|
312
|
+
const reads = pages.flatMap((page) => (page.read === null ? [] : [page.read]));
|
|
313
|
+
const first = reads[0];
|
|
314
|
+
if (first === undefined) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
if (first.kind === "directory") {
|
|
318
|
+
return directoryResultOf(first, summary);
|
|
319
|
+
}
|
|
320
|
+
const files = reads.flatMap((read) =>
|
|
321
|
+
read.kind === "file" ? (fileResultOf(read, summary) ?? []) : [],
|
|
322
|
+
);
|
|
323
|
+
const opening = files[0];
|
|
324
|
+
const closing = files[files.length - 1];
|
|
325
|
+
if (opening === undefined || closing === undefined) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
...opening,
|
|
330
|
+
mount: closing.mount,
|
|
331
|
+
content: files.map((file) => file.content).join(""),
|
|
332
|
+
nextOffset: closing.nextOffset,
|
|
333
|
+
totalLength: closing.totalLength,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A file whole, or a folder, as the REST API answers it, for `--json`. */
|
|
338
|
+
export const bodyOfFile = (result: FileResult | DirectoryResult): RestFile =>
|
|
339
|
+
"entries" in result ? directoryBody(result) : fileBody(result);
|
|
340
|
+
|
|
341
|
+
/** A file as `read_repo_file` lays it out for a model, whole; or what the folder holds. */
|
|
342
|
+
export function formatSkillFile(
|
|
343
|
+
read: RestSkillFile,
|
|
344
|
+
whole: FileResult | DirectoryResult | undefined,
|
|
345
|
+
): string {
|
|
346
|
+
if (read.status === "none" || read.address === null) {
|
|
347
|
+
return NO_ADDRESS;
|
|
348
|
+
}
|
|
349
|
+
if (read.status !== "ready") {
|
|
350
|
+
return refused("file", read);
|
|
351
|
+
}
|
|
352
|
+
if (whole === undefined) {
|
|
353
|
+
return `${head("file", read)}\nNothing was read.`;
|
|
354
|
+
}
|
|
355
|
+
return "entries" in whole ? renderDirectoryResult(whole) : renderFileResult(whole, CONSOLE_WORDS);
|
|
356
|
+
}
|
package/src/compat.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function inRange(version: Version, range: VersionRange): boolean {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* The common rule. Before 1.0, a command from the console's own version on and within the
|
|
78
|
-
* console's minor (`^0.1.18`): any release may change the contract until then (ADR-
|
|
78
|
+
* console's minor (`^0.1.18`): any release may change the contract until then (ADR-0034), so the
|
|
79
79
|
* console's own version is the floor, and the minor is the line. From 1.0, the console's major
|
|
80
80
|
* and a minor within one of the console's, either way, whatever the patch: a minor adds and a
|
|
81
81
|
* major breaks, as the version promises from then on.
|