@toloka-platform/sdk 0.1.0-preview.3 → 0.1.0-preview.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +2 -2
  2. package/bin/toloka.js +7 -0
  3. package/dist/cli/toloka.js +119 -114
  4. package/dist/cli/toloka.js.jsc +0 -0
  5. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  6. package/dist/cli/toloka.js.sha256 +1 -1
  7. package/docs/cli-v2.md +4 -0
  8. package/docs/cli.md +56 -5
  9. package/docs/getting-started.md +3 -3
  10. package/docs/limitations.md +1 -1
  11. package/openapi/gen/api.ts +22 -0
  12. package/openapi/gen/models/createDatasetBody.ts +2 -0
  13. package/openapi/gen/models/createDatasetBodyProfile.ts +2 -0
  14. package/openapi/gen/models/createDatasetUploadBodyFormat.ts +1 -1
  15. package/openapi/gen/models/dataset.ts +2 -0
  16. package/openapi/gen/models/datasetProfile.ts +3 -0
  17. package/openapi/gen/models/datasetProfileStamp.ts +6 -0
  18. package/openapi/gen/models/datasetProfileStampName.ts +2 -0
  19. package/openapi/gen/models/index.ts +5 -0
  20. package/openapi/gen/models/runCost.ts +7 -0
  21. package/package.json +4 -2
  22. package/skills/catalog.json +22 -0
  23. package/skills/{SKILL.md → toloka-build/SKILL.md} +1 -1
  24. package/skills/{references → toloka-build/references}/workflow-and-cli.md +14 -4
  25. package/skills/toloka-report-issue/SKILL.md +25 -0
  26. package/skills/toloka-report-issue/references/report-template.md +34 -0
  27. package/src/authoring/index.ts +216 -171
  28. package/src/cli/analytics.ts +53 -1
  29. package/src/cli/auth.ts +5 -5
  30. package/src/cli/credentials.ts +8 -2
  31. package/src/cli/file-system.ts +34 -0
  32. package/src/cli/format-error.ts +19 -0
  33. package/src/cli/init.ts +149 -194
  34. package/src/cli/program.ts +103 -21
  35. package/src/cli/report.ts +144 -0
  36. package/src/cli/skill.ts +363 -0
  37. package/src/pipeline-graph-v1.contract.json +5 -1
  38. /package/skills/{references → toloka-build/references}/node-semantics.md +0 -0
@@ -0,0 +1,363 @@
1
+ import { assert } from "es-toolkit";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { assertSafeDestination, entryStats, hasErrorCode } from "./file-system";
7
+ import { formatCliError } from "./format-error";
8
+
9
+ export const BUNDLED_SKILL_CATALOG_ROOT = fileURLToPath(new URL("../../skills", import.meta.url));
10
+ export const SKILL_CATALOG_MANIFEST = "catalog.json";
11
+ export const SKILL_INSTALL_ROOT = ".agents/skills";
12
+ export const SKILL_ENTRY_POINT = "SKILL.md";
13
+
14
+ const SKILL_CATALOG_DIRECTORY = "skills";
15
+ const MAX_SKILL_COUNT = 500;
16
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
17
+ const MAX_SKILL_ID_LENGTH = 64;
18
+ const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
19
+ const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
20
+
21
+ export type SkillDescriptor = {
22
+ readonly id: string;
23
+ readonly name: string;
24
+ readonly description: string;
25
+ readonly tags: readonly string[];
26
+ readonly version: string;
27
+ readonly digest: string;
28
+ readonly files: readonly string[];
29
+ };
30
+
31
+ export type SkillCatalog = {
32
+ readonly root: string;
33
+ readonly manifestPath: string;
34
+ readonly skills: readonly SkillDescriptor[];
35
+ };
36
+
37
+ export type SkillStatus = "not-installed" | "current" | "stale";
38
+
39
+ export type SkillListEntry = SkillDescriptor & { readonly status: SkillStatus };
40
+
41
+ export type InstalledSkill = {
42
+ readonly skill: SkillDescriptor;
43
+ readonly directory: string;
44
+ readonly replaced: boolean;
45
+ };
46
+
47
+ export type InstallSkillOptions = {
48
+ readonly catalog: SkillCatalog;
49
+ readonly sourceRoot: string;
50
+ readonly skillId: string;
51
+ readonly force?: boolean;
52
+ readonly rename?: typeof rename;
53
+ };
54
+
55
+ export type SkillIo = {
56
+ readonly stdout: (text: string) => void;
57
+ };
58
+
59
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
60
+ typeof value === "object" && value !== null && !Array.isArray(value);
61
+
62
+ const isStringArray = (value: unknown): value is string[] =>
63
+ Array.isArray(value) && value.every((item) => typeof item === "string");
64
+
65
+ const isNonEmptyString = (value: unknown): value is string =>
66
+ typeof value === "string" && value.trim().length > 0;
67
+
68
+ const isSafeSkillId = (value: unknown): value is string =>
69
+ typeof value === "string" && value.length <= MAX_SKILL_ID_LENGTH && SKILL_ID_PATTERN.test(value);
70
+
71
+ const isSafeRelativePath = (path: string) =>
72
+ !path.includes("\\") &&
73
+ !path.includes("\0") &&
74
+ path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
75
+
76
+ const parseDescriptor = (value: unknown, invalid: (detail: string) => string): SkillDescriptor => {
77
+ assert(isRecord(value), invalid("every skill must be an object."));
78
+ const { id, name, description, tags, version, digest, files } = value;
79
+ assert(
80
+ isSafeSkillId(id),
81
+ invalid(
82
+ `skill id ${JSON.stringify(id)} must be at most ${MAX_SKILL_ID_LENGTH} lowercase letters, digits, and single hyphens.`,
83
+ ),
84
+ );
85
+ assert(isNonEmptyString(name), invalid(`skill ${id} needs a name.`));
86
+ assert(isNonEmptyString(description), invalid(`skill ${id} needs a description.`));
87
+ assert(isStringArray(tags), invalid(`skill ${id} tags must be strings.`));
88
+ assert(isNonEmptyString(version), invalid(`skill ${id} needs a version.`));
89
+ assert(
90
+ typeof digest === "string" && DIGEST_PATTERN.test(digest),
91
+ invalid(`skill ${id} digest must be the sha256 hex digest of its files.`),
92
+ );
93
+ assert(
94
+ isStringArray(files) && files.includes(SKILL_ENTRY_POINT),
95
+ invalid(`skill ${id} files must include ${SKILL_ENTRY_POINT}.`),
96
+ );
97
+ assert(
98
+ files.every(isSafeRelativePath),
99
+ invalid(`skill ${id} files must be relative paths inside its directory.`),
100
+ );
101
+ assert(new Set(files).size === files.length, invalid(`skill ${id} lists a file more than once.`));
102
+ return { id, name, description, tags, version, digest, files };
103
+ };
104
+
105
+ const parseManifest = (source: string, manifestPath: string): unknown => {
106
+ try {
107
+ return JSON.parse(source);
108
+ } catch (error) {
109
+ throw new Error(`Invalid skill catalog ${manifestPath}: ${formatCliError(error)}`);
110
+ }
111
+ };
112
+
113
+ const isFileAt = async (path: string): Promise<boolean> => {
114
+ try {
115
+ return (await stat(path)).isFile();
116
+ } catch (error) {
117
+ if (hasErrorCode(error, "ENOENT", "ENOTDIR")) return false;
118
+ throw error;
119
+ }
120
+ };
121
+
122
+ const findSkillCatalogRootAbove = async (directory: string): Promise<string | undefined> => {
123
+ const candidate = join(directory, SKILL_CATALOG_DIRECTORY);
124
+ if (await isFileAt(join(candidate, SKILL_CATALOG_MANIFEST))) return candidate;
125
+ const parent = dirname(directory);
126
+ return parent === directory ? undefined : findSkillCatalogRootAbove(parent);
127
+ };
128
+
129
+ export async function resolveBundledSkillCatalogRoot(
130
+ runtimeEntry: string | undefined = process.argv[1],
131
+ ): Promise<string> {
132
+ const entry = runtimeEntry === undefined ? undefined : await realpath(runtimeEntry);
133
+ const runtimeRoot =
134
+ entry === undefined ? undefined : await findSkillCatalogRootAbove(dirname(entry));
135
+ if (runtimeRoot !== undefined) return runtimeRoot;
136
+ const searched =
137
+ entry === undefined
138
+ ? "process.argv[1] names no runtime entry"
139
+ : `none exists between ${entry} and the filesystem root`;
140
+ assert(
141
+ await isFileAt(join(BUNDLED_SKILL_CATALOG_ROOT, SKILL_CATALOG_MANIFEST)),
142
+ `Cannot locate the bundled skill catalog. An installed @toloka-platform/sdk must ship ${SKILL_CATALOG_DIRECTORY}/${SKILL_CATALOG_MANIFEST} in a directory above its runtime entry, but ${searched}, and the source checkout catalog ${BUNDLED_SKILL_CATALOG_ROOT} is absent. Reinstall @toloka-platform/sdk.`,
143
+ );
144
+ return BUNDLED_SKILL_CATALOG_ROOT;
145
+ }
146
+
147
+ const loadBundledSkillCatalog = async (): Promise<SkillCatalog> =>
148
+ loadSkillCatalog(await resolveBundledSkillCatalogRoot());
149
+
150
+ export async function loadSkillCatalog(catalogRoot: string): Promise<SkillCatalog> {
151
+ const manifestPath = join(catalogRoot, SKILL_CATALOG_MANIFEST);
152
+ const invalid = (detail: string) => `Invalid skill catalog ${manifestPath}: ${detail}`;
153
+ const { size } = await stat(manifestPath);
154
+ assert(
155
+ size <= MAX_MANIFEST_BYTES,
156
+ invalid(`the manifest is ${size} bytes; the limit is ${MAX_MANIFEST_BYTES} bytes.`),
157
+ );
158
+ const manifest = parseManifest(await readFile(manifestPath, "utf8"), manifestPath);
159
+ assert(
160
+ isRecord(manifest) && Array.isArray(manifest.skills),
161
+ invalid("expected an object with a skills array."),
162
+ );
163
+ assert(
164
+ manifest.skills.length <= MAX_SKILL_COUNT,
165
+ invalid(`${manifest.skills.length} skills exceed the limit of ${MAX_SKILL_COUNT}.`),
166
+ );
167
+ const skills = manifest.skills.map((skill) => parseDescriptor(skill, invalid));
168
+ const ids = new Set<string>();
169
+ for (const skill of skills) {
170
+ assert(!ids.has(skill.id), invalid(`skill id ${skill.id} appears more than once.`));
171
+ ids.add(skill.id);
172
+ }
173
+ return { root: catalogRoot, manifestPath, skills };
174
+ }
175
+
176
+ export async function computeSkillDigest(
177
+ directory: string,
178
+ files: readonly string[],
179
+ ): Promise<string> {
180
+ const hash = createHash("sha256");
181
+ for (const path of files.toSorted()) {
182
+ hash.update(path);
183
+ hash.update("\0");
184
+ hash.update(await readFile(join(directory, path)));
185
+ hash.update("\0");
186
+ }
187
+ return hash.digest("hex");
188
+ }
189
+
190
+ export const searchSkills = (
191
+ skills: readonly SkillDescriptor[],
192
+ query: string,
193
+ ): SkillDescriptor[] => {
194
+ const needle = query.trim().toLowerCase();
195
+ return skills.filter((skill) =>
196
+ [skill.id, skill.name, skill.description, ...skill.tags].some((field) =>
197
+ field.toLowerCase().includes(needle),
198
+ ),
199
+ );
200
+ };
201
+
202
+ export async function skillStatus(
203
+ sourceRoot: string,
204
+ skill: SkillDescriptor,
205
+ ): Promise<SkillStatus> {
206
+ const directory = join(sourceRoot, SKILL_INSTALL_ROOT, skill.id);
207
+ if ((await entryStats(directory)) === undefined) return "not-installed";
208
+ try {
209
+ return (await computeSkillDigest(directory, skill.files)) === skill.digest
210
+ ? "current"
211
+ : "stale";
212
+ } catch (error) {
213
+ if (hasErrorCode(error, "ENOENT", "ENOTDIR")) return "stale";
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ export const listSkills = ({
219
+ sourceRoot,
220
+ skills,
221
+ }: {
222
+ readonly sourceRoot: string;
223
+ readonly skills: readonly SkillDescriptor[];
224
+ }): Promise<SkillListEntry[]> =>
225
+ Promise.all(
226
+ skills.map(async (skill) => ({ ...skill, status: await skillStatus(sourceRoot, skill) })),
227
+ );
228
+
229
+ const swapDirectories = async (
230
+ renameEntry: typeof rename,
231
+ replacement: string,
232
+ destination: string,
233
+ backup: string,
234
+ ) => {
235
+ await renameEntry(destination, backup);
236
+ try {
237
+ await renameEntry(replacement, destination);
238
+ } catch (error) {
239
+ try {
240
+ await renameEntry(backup, destination);
241
+ } catch (restoreError) {
242
+ throw new Error(
243
+ `Failed to replace ${destination}; the previous version is preserved at ${backup}. ${formatCliError(restoreError)}`,
244
+ { cause: error },
245
+ );
246
+ }
247
+ throw error;
248
+ }
249
+ await rm(backup, { recursive: true, force: true });
250
+ };
251
+
252
+ export async function installSkill({
253
+ catalog,
254
+ sourceRoot,
255
+ skillId,
256
+ force = false,
257
+ rename: renameEntry = rename,
258
+ }: InstallSkillOptions): Promise<InstalledSkill> {
259
+ const skill = catalog.skills.find((candidate) => candidate.id === skillId);
260
+ assert(
261
+ skill,
262
+ `Skill ${JSON.stringify(skillId)} is not in the catalog. Run \`toloka skill list\` to see the available skills.`,
263
+ );
264
+ const relativeDestination = `${SKILL_INSTALL_ROOT}/${skill.id}`;
265
+ await assertSafeDestination(sourceRoot, relativeDestination);
266
+ const destination = join(sourceRoot, relativeDestination);
267
+ const existing = await entryStats(destination);
268
+ assert(
269
+ existing === undefined || force,
270
+ `${relativeDestination} already exists. Re-run with --force to replace it with the catalog version of ${skill.id}.`,
271
+ );
272
+
273
+ const skillsRoot = dirname(destination);
274
+ await mkdir(skillsRoot, { recursive: true });
275
+ const temporary = await mkdtemp(join(skillsRoot, `.${skill.id}.tmp-`));
276
+ try {
277
+ for (const path of skill.files) {
278
+ const target = join(temporary, path);
279
+ await mkdir(dirname(target), { recursive: true });
280
+ await copyFile(join(catalog.root, skill.id, path), target);
281
+ }
282
+ assert(
283
+ (await computeSkillDigest(temporary, skill.files)) === skill.digest,
284
+ `Skill ${skill.id} failed its digest check: the packaged files do not match the catalog digest ${skill.digest}. Nothing was installed.`,
285
+ );
286
+ if (existing === undefined) {
287
+ await renameEntry(temporary, destination);
288
+ } else {
289
+ await swapDirectories(renameEntry, temporary, destination, `${temporary}.backup`);
290
+ }
291
+ } catch (error) {
292
+ await rm(temporary, { recursive: true, force: true });
293
+ throw error;
294
+ }
295
+ return { skill, directory: destination, replaced: existing !== undefined };
296
+ }
297
+
298
+ const formatEntries = (entries: readonly SkillListEntry[], json: boolean): string => {
299
+ if (json) return JSON.stringify(entries, undefined, 2);
300
+ const idWidth = Math.max(...entries.map((entry) => entry.id.length));
301
+ const statusWidth = Math.max(...entries.map((entry) => entry.status.length));
302
+ return entries
303
+ .flatMap((entry) => [
304
+ `${entry.id.padEnd(idWidth)} ${entry.status.padEnd(statusWidth)} ${entry.name}`,
305
+ ` ${entry.description}`,
306
+ ])
307
+ .join("\n");
308
+ };
309
+
310
+ export async function skillListCommand({
311
+ sourceRoot,
312
+ json,
313
+ io,
314
+ }: {
315
+ readonly sourceRoot: string;
316
+ readonly json: boolean;
317
+ readonly io: SkillIo;
318
+ }): Promise<void> {
319
+ const catalog = await loadBundledSkillCatalog();
320
+ io.stdout(formatEntries(await listSkills({ sourceRoot, skills: catalog.skills }), json));
321
+ }
322
+
323
+ export async function skillSearchCommand({
324
+ sourceRoot,
325
+ query,
326
+ json,
327
+ io,
328
+ }: {
329
+ readonly sourceRoot: string;
330
+ readonly query: string;
331
+ readonly json: boolean;
332
+ readonly io: SkillIo;
333
+ }): Promise<void> {
334
+ const catalog = await loadBundledSkillCatalog();
335
+ const matches = await listSkills({ sourceRoot, skills: searchSkills(catalog.skills, query) });
336
+ if (json || matches.length > 0) {
337
+ io.stdout(formatEntries(matches, json));
338
+ return;
339
+ }
340
+ io.stdout(
341
+ `No skills match ${JSON.stringify(query)}. Run \`toloka skill list\` to see the catalog.`,
342
+ );
343
+ }
344
+
345
+ export async function skillInstallCommand({
346
+ sourceRoot,
347
+ skillId,
348
+ force,
349
+ io,
350
+ }: {
351
+ readonly sourceRoot: string;
352
+ readonly skillId: string;
353
+ readonly force: boolean;
354
+ readonly io: SkillIo;
355
+ }): Promise<void> {
356
+ const catalog = await loadBundledSkillCatalog();
357
+ const installed = await installSkill({ catalog, sourceRoot, skillId, force });
358
+ const relativeDestination = `${SKILL_INSTALL_ROOT}/${installed.skill.id}`;
359
+ const fileCount = installed.skill.files.length;
360
+ io.stdout(
361
+ `${installed.replaced ? "Replaced" : "Installed"} ${installed.skill.id} in ${relativeDestination} (${fileCount} ${fileCount === 1 ? "file" : "files"}). Start with ${relativeDestination}/${SKILL_ENTRY_POINT}.`,
362
+ );
363
+ }
@@ -1633,10 +1633,14 @@
1633
1633
  "llmInstruction": {
1634
1634
  "default": "",
1635
1635
  "type": "string"
1636
+ },
1637
+ "maxCostPerAttempt": {
1638
+ "type": "number"
1636
1639
  }
1637
1640
  },
1638
1641
  "required": [
1639
- "audienceType"
1642
+ "audienceType",
1643
+ "maxCostPerAttempt"
1640
1644
  ],
1641
1645
  "additionalProperties": false
1642
1646
  }