@qualve/ai 0.0.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qualve/ai",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "LLM task plugin for Qualve, with adapters for Gemini, OpenAI, and Claude.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -20,12 +20,21 @@
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "https://github.com/qualve/ai.git"
23
+ "url": "git+https://github.com/qualve/ai.git"
24
24
  },
25
25
  "contributors": [
26
26
  "Dmitry Sharabin",
27
27
  "Lea Verou"
28
28
  ],
29
+ "scripts": {
30
+ "release": "npm login && release-it"
31
+ },
32
+ "release-it": {
33
+ "git": {
34
+ "tagName": "${npm.name}@${version}",
35
+ "commitMessage": "Release ${npm.name}@${version}"
36
+ }
37
+ },
29
38
  "peerDependencies": {
30
39
  "qualve": "*"
31
40
  },
@@ -33,7 +42,8 @@
33
42
  "prettier": "^3.8.1",
34
43
  "prettier-plugin-brace-style": "^0.10.0",
35
44
  "prettier-plugin-merge": "^0.10.0",
36
- "prettier-plugin-space-before-function-paren": "^0.1.0"
45
+ "prettier-plugin-space-before-function-paren": "^0.1.0",
46
+ "release-it": "^20.0.0"
37
47
  },
38
48
  "dependencies": {
39
49
  "@qualve/llm": "*",
package/.prettierignore DELETED
@@ -1,2 +0,0 @@
1
- package-lock.json
2
- package.json
package/.prettierrc DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "plugins": [
3
- "prettier-plugin-brace-style",
4
- "prettier-plugin-space-before-function-paren",
5
- "prettier-plugin-merge"
6
- ],
7
- "braceStyle": "stroustrup",
8
- "arrowParens": "avoid",
9
- "bracketSpacing": true,
10
- "endOfLine": "auto",
11
- "semi": true,
12
- "singleQuote": false,
13
- "tabWidth": 4,
14
- "useTabs": true,
15
- "trailingComma": "all",
16
- "printWidth": 100
17
- }
package/core/README.md DELETED
@@ -1,74 +0,0 @@
1
- # @qualve/llm
2
-
3
- Core LLM task framework for [Qualve](https://npmjs.com/package/qualve).
4
- Provides the `LLMTask` base class that all LLM provider adapters extend.
5
-
6
- If you want all providers out of the box, install [@qualve/llms](https://npmjs.com/package/@qualve/llms) instead.
7
-
8
- ## Setup
9
-
10
- Requires **Node.js v23+**.
11
-
12
- ```sh
13
- npm install @qualve/llm
14
- ```
15
-
16
- ## Usage
17
-
18
- Import alongside a provider to register the `llm` task type:
19
-
20
- ```js
21
- import "@qualve/anthropic"; // or @qualve/openai, @qualve/googleai
22
- ```
23
-
24
- ### Writing a custom provider
25
-
26
- ```js
27
- import { LLMTask } from "@qualve/llm";
28
-
29
- export default class MyProvider extends LLMTask {
30
- static id = "my-provider";
31
- static name = "My Provider";
32
- static models = ["my-model-v1"];
33
- static capabilities = {};
34
-
35
- // Required: implement these abstract methods
36
- async uploadFile (filepath, { mimeType, contents }) { /* ... */ }
37
- async getFile (filepath) { /* ... */ }
38
- async deleteFile (filepath) { /* ... */ }
39
- async listFiles () { /* ... */ }
40
- async createStream () { /* ... */ }
41
- }
42
-
43
- LLMTask.register(MyProvider);
44
- ```
45
-
46
- ## API
47
-
48
- ### `LLMTask`
49
-
50
- Extends the base Qualve `Task` class with LLM-specific functionality:
51
-
52
- - **Provider dispatch** — `LLMTask.create()` routes to the registered provider based on `task.llm`
53
- - **File management** — Upload, retrieve, and manage files on the provider
54
- - **Streaming** — `handleStream()` writes streamed responses to disk with backpressure handling
55
- - **Prompt helpers** — `this.inputFile()`, `this.inputFiles()`, `this.outputFile()` generate prompt text describing task I/O
56
- - **Thinking levels** — Normalized across providers via `thinkingLevels` and per-provider `levelMap`
57
- - **Stop reasons** — Normalized stop reasons (`COMPLETE`, `MAX_TOKENS`, `ABORTED`, `UNKNOWN`)
58
-
59
- ### Abstract methods (providers must implement)
60
-
61
- | Method | Description |
62
- | --- | --- |
63
- | `uploadFile(filepath, { mimeType, contents })` | Upload data to the provider |
64
- | `getFile(filepath)` | Retrieve a previously uploaded file, or `null` |
65
- | `deleteFile(filepath)` | Delete a previously uploaded file |
66
- | `listFiles()` | List all uploaded files |
67
- | `createStream()` | Create the streaming API call; returns `{ stream, transformChunk, onChunk?, onFinish? }` |
68
-
69
- ### Optional overrides
70
-
71
- | Method | Description |
72
- | --- | --- |
73
- | `getStatus(chunk)` | Extract a human-readable status from a streaming chunk |
74
- | `countTokens()` | Count input tokens for a dry run |
package/core/package.json DELETED
@@ -1,28 +0,0 @@
1
- {
2
- "name": "@qualve/llm",
3
- "version": "0.0.1",
4
- "description": "Core LLM task framework for Qualve.",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=23"
8
- },
9
- "main": "src/index.js",
10
- "exports": {
11
- ".": "./src/index.js"
12
- },
13
- "repository": {
14
- "type": "git",
15
- "url": "https://github.com/qualve/ai.git",
16
- "directory": "core"
17
- },
18
- "author": "Lea Verou",
19
- "contributors": [
20
- "Dmitry Sharabin"
21
- ],
22
- "peerDependencies": {
23
- "qualve": "*"
24
- },
25
- "dependencies": {
26
- "dedent": "^1.7.2"
27
- }
28
- }
package/core/src/index.js DELETED
@@ -1 +0,0 @@
1
- export { default as LLMTask } from "./types/llm.js";
@@ -1,83 +0,0 @@
1
- import path from "node:path";
2
-
3
- /**
4
- * Describe a single input file for inclusion in the prompt.
5
- * Called with `this` bound to the LLMTask instance.
6
- * @param {object} file
7
- * @returns {string}
8
- */
9
- export function inputFile (file) {
10
- let ret = [];
11
-
12
- if (file.description && !this.capabilities.inputDescriptions) {
13
- ret.push(`containing ${file.description}`);
14
- }
15
-
16
- if (file.schema && !this.capabilities.inputSchema) {
17
- ret.push(`follows the JSON schema: ${JSON.stringify(file.schema, null, "\t")}`);
18
- }
19
-
20
- ret = ret.join(" and ");
21
-
22
- if (!ret.startsWith("containing")) {
23
- ret = "which " + ret;
24
- }
25
-
26
- ret = `\`${path.basename(file.filePath)}\` ${ret}.`;
27
-
28
- if (file.schema) {
29
- ret += "\nRead the field descriptions in the JSON schema for details on each field.";
30
- }
31
-
32
- return ret;
33
- }
34
-
35
- /**
36
- * Describe all input files for inclusion in the prompt.
37
- * Called with `this` bound to the LLMTask instance.
38
- * @param {Array} files
39
- * @returns {string}
40
- */
41
- export function inputFiles (files) {
42
- if (files.length === 0) {
43
- return "";
44
- }
45
-
46
- return `I provide the contents of the following files:
47
- ${files.map(file => inputFile.call(this, file)).join("\n")}`;
48
- }
49
-
50
- /**
51
- * Describe the expected output file for inclusion in the prompt.
52
- * Called with `this` bound to the LLMTask instance.
53
- * @param {object} file
54
- * @returns {string}
55
- */
56
- export function outputFile (file) {
57
- let ret = [`Produce a JSON file that I’m going to save as \`${path.basename(file.filePath)}\``];
58
-
59
- if (file.description && !this.capabilities.outputDescriptions) {
60
- ret.push(`containing ${file.description}`);
61
- }
62
-
63
- if (file.schema && !this.capabilities.outputSchema) {
64
- ret.push(`following the JSON schema: ${JSON.stringify(file.schema, null, "\t")}`);
65
- }
66
-
67
- if (ret.length <= 1) {
68
- ret = ret[0];
69
- }
70
- else {
71
- if (ret.length === 3) {
72
- ret[2] = "and " + ret[2];
73
- }
74
-
75
- ret = ret.join(", ");
76
- }
77
-
78
- if (file.schema) {
79
- ret += ".\nRead the field descriptions in the JSON schema for details on each field.";
80
- }
81
-
82
- return ret;
83
- }
@@ -1,404 +0,0 @@
1
- import * as path from "node:path";
2
- import { readFileSync } from "node:fs";
3
- import { existsSync } from "node:fs";
4
- import { loadEnvFile } from "node:process";
5
- import Task from "qualve/task";
6
- import { ProgressIndicator, addFilenameSuffix, readJSONSync } from "qualve/util";
7
- import { handleStream, dedent } from "../util.js";
8
- import * as prompts from "../prompts.js";
9
- import options from "qualve/options";
10
-
11
- Object.assign(options, {
12
- // Options specific to LLM tasks
13
- llm: {},
14
- model: {},
15
- thinking: {},
16
- });
17
-
18
- export default class LLMTask extends Task {
19
- // Subclass must define these
20
- client = null;
21
-
22
- /**
23
- * Normalized stop reason vocabulary, consistent across all providers.
24
- * Each provider subclass maps its own provider-specific stop states to these values.
25
- *
26
- * - `COMPLETE` — The model finished generating its full response.
27
- * - `MAX_TOKENS` — The response was truncated because it hit the output token limit.
28
- * - `ABORTED` — The provider refused to produce output (safety, policy, PII, etc.).
29
- * - `UNKNOWN` — The provider returned a stop state we don't have a mapping for.
30
- * @enum {string}
31
- */
32
- static stopReasons = {
33
- COMPLETE: "complete",
34
- MAX_TOKENS: "max_tokens",
35
- ABORTED: "aborted",
36
- UNKNOWN: "unknown",
37
- };
38
-
39
- /**
40
- * Canonical ordered list of all thinking levels across all providers.
41
- * Subclasses use {@link levelMap} to remap levels they don't natively support.
42
- */
43
- static thinkingLevels = ["none", "minimal", "low", "medium", "high", "xhigh"];
44
-
45
- static type = "llm";
46
- static capabilities = {};
47
-
48
- static #registry = new Map();
49
-
50
- /**
51
- * Register an LLM provider so LLMTask.create() can dispatch to it by `task.llm`.
52
- * Reads `SubClass.id` as the registry key.
53
- * Each provider calls this after its own definition to self-register.
54
- * @param {typeof LLMTask} SubClass
55
- */
56
- static register (SubClass) {
57
- LLMTask.#registry.set(SubClass.id, SubClass);
58
- }
59
-
60
- /**
61
- * Select and instantiate the right provider subclass based on `task.llm`.
62
- * Overrides the base Task.create factory to add provider dispatch.
63
- */
64
- static create (task, ...args) {
65
- let id = task.llm ?? "gemini";
66
- let Provider = LLMTask.#registry.get(id);
67
- if (!Provider) {
68
- throw new Error(
69
- `Unknown LLM provider: "${id}". Available: ${[...LLMTask.#registry.keys()].join(", ")}`,
70
- );
71
- }
72
- return new Provider(task, ...args);
73
- }
74
-
75
- get capabilities () {
76
- return this.constructor.capabilities;
77
- }
78
-
79
- /** Provider display name (e.g., "Gemini"). */
80
- get name () {
81
- return this.constructor.name;
82
- }
83
-
84
- /**
85
- * Provider ID string (e.g., "gemini", "claude").
86
- * This getter also prevents the Task constructor from overwriting `this.llm`
87
- * with the raw string from task config data.
88
- */
89
- get llm () {
90
- return this.constructor.id;
91
- }
92
-
93
- /**
94
- * LLM tasks default to sequential for question expansion and bounded for batch,
95
- * to avoid overwhelming the provider API.
96
- * Task definitions can override this by setting `concurrency` explicitly.
97
- */
98
- get concurrency () {
99
- return this.task.concurrency ?? (this.batched ? 5 : 1);
100
- }
101
-
102
- constructor (task, args) {
103
- super(task, args);
104
-
105
- if (existsSync(".env")) {
106
- loadEnvFile(".env");
107
- }
108
-
109
- // Default to the first model in the provider's list, also falling back
110
- // if the task's hardcoded model isn't supported by this provider (e.g., when switching providers via --llm).
111
- if (!this.constructor.models.includes(this.model)) {
112
- this.model = this.constructor.models[0];
113
- }
114
-
115
- // Normalize thinking level for this provider, or unset it if not supported.
116
- if (
117
- this.constructor.capabilities.thinkingLevel &&
118
- LLMTask.thinkingLevels.includes(this.thinking)
119
- ) {
120
- this.thinking = this.constructor.levelMap?.[this.thinking] ?? this.thinking;
121
- }
122
- else {
123
- this.thinking = undefined;
124
- }
125
-
126
- Object.assign(this.debug, {
127
- llm: this.name,
128
- model: this.model,
129
- thinking: this.thinking,
130
- itemsPerPage: this.itemsPerPage,
131
- });
132
- }
133
-
134
- /**
135
- * Read a file if no contents are provided and prepare it for upload.
136
- * Mainly intended to be used internally.
137
- * @protected
138
- * @param {string} filepath
139
- * @param {object} [options]
140
- * @param {string} [options.mimeType="application/json"] - The MIME type of the file.
141
- * @param {string|object|Array} [options.contents] - The file contents to upload. Reads filepath if not provided.
142
- */
143
- readFile (filepath, options = {}) {
144
- options.mimeType ??= "application/json";
145
- let isJSON = options.mimeType === "application/json";
146
- options.contents ??= isJSON ? readJSONSync(filepath) : readFileSync(filepath);
147
-
148
- if (isJSON && typeof options.contents !== "string") {
149
- options.contents = JSON.stringify(options.contents, (k, v) => v ?? undefined);
150
- }
151
-
152
- return options;
153
- }
154
-
155
- /**
156
- * Read a file, prepare its contents, and upload it to the provider.
157
- * For JSON files, this minifies the data and strips nulls to reduce token usage.
158
- * @param {string} filepath
159
- * @param {object} [options]
160
- * @param {string} [options.mimeType="application/json"] - The MIME type of the file.
161
- * @param {string|object|Array} [options.contents] - The file contents to upload. Reads filepath if not provided.
162
- */
163
- sendData (filepath, options = {}) {
164
- options = this.readFile(filepath, options);
165
- return this.uploadFile(filepath, options);
166
- }
167
-
168
- /**
169
- /**
170
- * Resolve a local filepath to a stable remote filename, namespaced by entity.
171
- * @param {string} filepath
172
- * @returns {{ name: string, dirName: string }}
173
- */
174
- getFileInfo (filepath) {
175
- // FIXME there is an implicit assumption here that dirName is equal to an id, which is not always the case
176
- let dirName = path.basename(path.dirname(filepath));
177
- let prefix = this.entityModel?.truncatedIds?.[dirName];
178
- let name = path.basename(filepath);
179
-
180
- // Make sure the filename is unique per entity by prefixing it with the truncated parent directory name.
181
- // For other files (e.g., shared data), no prefix is needed since they are already unique.
182
- name = (prefix ? prefix + "-" : "") + name;
183
- return { name, dirName };
184
- }
185
-
186
- /**
187
- * Ensure a file is available on the provider, uploading it if necessary.
188
- * Freshness is determined by the file-level `fresh` option, falling back to the task-level `this.fresh`.
189
- * @param {string} filepath
190
- * @param {object} [options]
191
- * @param {string|object|Array} [options.contents] - In-memory file contents. Skips disk read when provided.
192
- * @param {boolean} [options.fresh] - Force re-upload for this specific file.
193
- */
194
- async getRemoteFile (filepath, options = {}) {
195
- let fresh = options.fresh ?? this.fresh;
196
-
197
- if (fresh) {
198
- this.info(`Removing previously uploaded file ${filepath} ...`);
199
- await this.deleteFile(filepath);
200
- }
201
-
202
- let ret = !fresh ? await this.getFile(filepath) : null;
203
- if (!ret) {
204
- this.info(`Uploading ${filepath} ...`);
205
- ret = await this.sendData(filepath, options);
206
- }
207
-
208
- this.info(`Source file ${filepath} ready`);
209
- return ret;
210
- }
211
-
212
- /**
213
- * Ensure all input files are available on the provider, populating `entry.remoteFile`.
214
- * @param {Array} input
215
- */
216
- async getRemoteFiles (input) {
217
- await Promise.all(
218
- input.map(async entry => {
219
- entry.remoteFile ??= await this.getRemoteFile(entry.filePath, {
220
- contents: entry.contents,
221
- fresh: entry.fresh,
222
- });
223
- }),
224
- );
225
- }
226
-
227
- // Abstract — subclasses must override
228
-
229
- /**
230
- * Low-level: upload data to the provider.
231
- * Use {@link sendData} for most things instead.
232
- * @protected
233
- * @param {string} filepath
234
- * @param {object} options
235
- * @param {string} options.contents - The file contents to upload.
236
- * @param {string} options.mimeType - The MIME type of the file.
237
- */
238
- uploadFile (filepath, options) {
239
- throw this.notImplemented();
240
- }
241
-
242
- /** Retrieve a previously uploaded file from the provider, or null if not found. */
243
- getFile (filepath) {
244
- throw this.notImplemented();
245
- }
246
-
247
- /** Delete a previously uploaded file from the provider. */
248
- deleteFile (filepath) {
249
- throw this.notImplemented();
250
- }
251
-
252
- /** List all files currently uploaded to the provider. */
253
- listFiles () {
254
- throw this.notImplemented();
255
- }
256
-
257
- /** Create the streaming API call for this task. Returns stream + handler callbacks. */
258
- createStream () {
259
- throw this.notImplemented();
260
- }
261
-
262
- // To be overridden
263
-
264
- /** Extract a human-readable status message from a streaming chunk. */
265
- getStatus (chunk) {}
266
-
267
- /** Count the total input tokens for this task. Returns undefined if unsupported. */
268
- async countTokens () {}
269
-
270
- /** Describe a single input file for inclusion in the prompt. */
271
- inputFile (file) {
272
- return prompts.inputFile.call(this, file);
273
- }
274
-
275
- /** Describe all input files for inclusion in the prompt. */
276
- inputFiles (files) {
277
- return prompts.inputFiles.call(this, files);
278
- }
279
-
280
- /** Describe the expected output file for inclusion in the prompt. */
281
- outputFile (file) {
282
- return prompts.outputFile.call(this, file);
283
- }
284
-
285
- /**
286
- * Normalize a prompts value to a flat array of strings.
287
- * Accepts a string, array, or function (called with the current entity).
288
- */
289
- normalizePrompts (prompts) {
290
- if (!prompts) {
291
- return [];
292
- }
293
-
294
- if (typeof prompts === "function") {
295
- prompts = prompts.call(this, this.entity);
296
- }
297
-
298
- // Do not convert to else if, function may return a string or an array
299
- if (typeof prompts === "string") {
300
- prompts = dedent(prompts);
301
- }
302
- else if (Array.isArray(prompts)) {
303
- return prompts.flatMap(prompt => this.normalizePrompts(prompt));
304
- }
305
-
306
- return [prompts];
307
- }
308
-
309
- async postInit () {
310
- await super.postInit();
311
-
312
- this.system = this.normalizePrompts(this.system);
313
- this.prompt = this.normalizePrompts(this.prompt);
314
-
315
- const capabilities = this.capabilities;
316
-
317
- if (
318
- this.input?.length > 0 &&
319
- !capabilities.inputSchema &&
320
- !capabilities.inputDescriptions
321
- ) {
322
- // Incorporate file descriptions and schemas into the prompt
323
- this.prompt.push(this.inputFiles(this.input));
324
- }
325
-
326
- if (this.output) {
327
- this.prompt.push(this.outputFile(this.output));
328
- }
329
-
330
- Object.assign(this.debug, { system: this.system, prompt: this.prompt });
331
- }
332
-
333
- async runTask () {
334
- if (this.dryRun) {
335
- if (!this.batched) {
336
- this.debug.tokens = await this.countTokens();
337
- }
338
-
339
- return;
340
- }
341
-
342
- if (this.input) {
343
- await this.getRemoteFiles(this.input);
344
- }
345
-
346
- // If no progress indicator exists (standalone task, not part of a multiple run),
347
- // install one so chunk status gets logUpdate-based in-place display.
348
- // Subtasks in a multiple run already have an indicator (child of the coordinator's).
349
- let ownedIndicator = !this.progressIndicator;
350
- if (ownedIndicator) {
351
- this.progressIndicator = new ProgressIndicator({
352
- status: `${this.title} with ${this.name}...`,
353
- });
354
- }
355
-
356
- const streamParams = await this.createStream();
357
- let chunksReceived = 0;
358
-
359
- let text;
360
- try {
361
- text = await handleStream({
362
- ...streamParams,
363
- outputPath: this.output?.filePath,
364
- onChunk: chunk => {
365
- chunksReceived++;
366
- let status = this.getStatus(chunk);
367
- status = status
368
- ? `Chunk ${chunksReceived}: ${status}`
369
- : `${chunksReceived} chunks received...`;
370
- this.info(status);
371
-
372
- // The explicit onChunk above shadows the one from streamParams, so call it manually.
373
- streamParams.onChunk?.(chunk);
374
- },
375
- });
376
- }
377
- catch (e) {
378
- // var hoists `error` out of the catch block so it's accessible after the finally.
379
- var error = e;
380
- }
381
- finally {
382
- if (ownedIndicator) {
383
- this.progressIndicator?.stop();
384
- this.progressIndicator = null;
385
- }
386
- }
387
-
388
- let outputPath = this.output?.filePath;
389
-
390
- if (outputPath && error) {
391
- outputPath = addFilenameSuffix(outputPath, ".tmp");
392
- }
393
-
394
- return {
395
- outputPath,
396
- size: chunksReceived,
397
- sizeUnit: "chunk",
398
- error,
399
- result: text,
400
- };
401
- }
402
- }
403
-
404
- Task.register(LLMTask);