@alisio/core 0.1.0-alpha.10

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/application.d.ts +225 -0
  4. package/dist/application.js +950 -0
  5. package/dist/config.d.ts +481 -0
  6. package/dist/config.js +592 -0
  7. package/dist/core/compaction.d.ts +136 -0
  8. package/dist/core/compaction.js +404 -0
  9. package/dist/core/contracts.d.ts +147 -0
  10. package/dist/core/contracts.js +1 -0
  11. package/dist/core/registry.d.ts +8 -0
  12. package/dist/core/registry.js +54 -0
  13. package/dist/core/runner.d.ts +160 -0
  14. package/dist/core/runner.js +625 -0
  15. package/dist/extensions/registry.d.ts +28 -0
  16. package/dist/extensions/registry.js +58 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.js +28 -0
  19. package/dist/integrations/herdr.d.ts +34 -0
  20. package/dist/integrations/herdr.js +174 -0
  21. package/dist/mcp/connector.d.ts +65 -0
  22. package/dist/mcp/connector.js +423 -0
  23. package/dist/plugins/host.d.ts +169 -0
  24. package/dist/plugins/host.js +585 -0
  25. package/dist/plugins/install.d.ts +100 -0
  26. package/dist/plugins/install.js +322 -0
  27. package/dist/providers/openai-compatible.d.ts +14 -0
  28. package/dist/providers/openai-compatible.js +228 -0
  29. package/dist/providers/registry.d.ts +35 -0
  30. package/dist/providers/registry.js +74 -0
  31. package/dist/providers/routing.d.ts +11 -0
  32. package/dist/providers/routing.js +41 -0
  33. package/dist/providers/settings.d.ts +30 -0
  34. package/dist/providers/settings.js +80 -0
  35. package/dist/resources/context.d.ts +44 -0
  36. package/dist/resources/context.js +171 -0
  37. package/dist/resources/prompts.d.ts +77 -0
  38. package/dist/resources/prompts.js +182 -0
  39. package/dist/resources/skills.d.ts +82 -0
  40. package/dist/resources/skills.js +214 -0
  41. package/dist/runtime/fs.d.ts +9 -0
  42. package/dist/runtime/fs.js +73 -0
  43. package/dist/runtime/modules.d.ts +16 -0
  44. package/dist/runtime/modules.js +83 -0
  45. package/dist/runtime/paths.d.ts +4 -0
  46. package/dist/runtime/paths.js +42 -0
  47. package/dist/runtime/process.d.ts +25 -0
  48. package/dist/runtime/process.js +122 -0
  49. package/dist/runtime/sqlite.d.ts +3 -0
  50. package/dist/runtime/sqlite.js +85 -0
  51. package/dist/runtime/store.d.ts +38 -0
  52. package/dist/runtime/store.js +259 -0
  53. package/dist/sessions/children.d.ts +55 -0
  54. package/dist/sessions/children.js +233 -0
  55. package/dist/startup/index.d.ts +41 -0
  56. package/dist/startup/index.js +105 -0
  57. package/dist/startup/mascot.d.ts +6 -0
  58. package/dist/startup/mascot.js +30 -0
  59. package/dist/startup/screen.d.ts +18 -0
  60. package/dist/startup/screen.js +117 -0
  61. package/dist/startup/text.d.ts +19 -0
  62. package/dist/startup/text.js +68 -0
  63. package/dist/tools/execute.d.ts +16 -0
  64. package/dist/tools/execute.js +96 -0
  65. package/dist/tools/search.d.ts +69 -0
  66. package/dist/tools/search.js +232 -0
  67. package/dist/tools/standard.d.ts +22 -0
  68. package/dist/tools/standard.js +419 -0
  69. package/dist/tools/webfetch.d.ts +26 -0
  70. package/dist/tools/webfetch.js +119 -0
  71. package/dist/trust.d.ts +34 -0
  72. package/dist/trust.js +113 -0
  73. package/dist/version.d.ts +7 -0
  74. package/dist/version.js +26 -0
  75. package/package.json +62 -0
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Installer for npm plugin packages: `alisio install npm:<package>[@<version>]`.
3
+ *
4
+ * One shared routine (`installPlugin`) backs both the CLI command (`cliInstall`) and the
5
+ * host-owned `plugin_install` tool for the agent, so the two can never drift apart. The install
6
+ * target is the GLOBAL plugins directory `<configHome>/plugins` (default `~/.config/alisio/plugins`),
7
+ * installed with `npm install --prefix`; the package NAME is persisted in the global configuration
8
+ * `plugins` array (never the resolved filesystem path), keeping entries portable. Loading the plugin
9
+ * still follows the existing executable-plugin policy: it runs in-process wherever it is loaded and
10
+ * is disabled entirely under `--read-only`.
11
+ */
12
+ import { chmod, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { textResult } from "@alisio/sdk";
15
+ import { configHome } from "../config.js";
16
+ import { readJson } from "../runtime/fs.js";
17
+ import { PLUGIN_KEYWORD } from "../runtime/modules.js";
18
+ import { runProcess } from "../runtime/process.js";
19
+ /** npm package name: scope (optional) followed by the name; both start with an alphanumeric. */
20
+ const PACKAGE_PART = "[A-Za-z0-9][A-Za-z0-9._-]*";
21
+ const NAME_RE = new RegExp(`^(?:@${PACKAGE_PART}\\/)?${PACKAGE_PART}$`);
22
+ /** Version specifier: npm's `@<version>` part, restricted to the same safe character set. */
23
+ const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
24
+ /**
25
+ * Validates an install spec without touching the network: `npm:<package>[@<version>]` (canonical),
26
+ * a bare package name (same resolution), and nothing else. Rejects shell metacharacters, `..` /
27
+ * absolute paths, and unknown prefixes (`registry:`, `git:`, `file:`, URLs) with a clear error.
28
+ */
29
+ export function parsePluginSpec(spec) {
30
+ if (!spec || !spec.trim())
31
+ throw new Error("Plugin spec is empty: use npm:<package>[@<version>] or a bare package name, for example npm:plugin-openrouter");
32
+ const trimmed = spec.trim();
33
+ let rest = trimmed;
34
+ if (trimmed.startsWith("npm:"))
35
+ rest = trimmed.slice(4);
36
+ else {
37
+ const colon = trimmed.indexOf(":");
38
+ if (colon >= 0)
39
+ throw new Error(`Unsupported plugin spec prefix "${trimmed.slice(0, colon)}:": only npm:<package>[@<version>] ` +
40
+ `or a bare package name is supported (registry:/git:/file:/http: specs are not)` +
41
+ (trimmed.startsWith("registry:")
42
+ ? "; pass the package name directly instead of a registry URL"
43
+ : ""));
44
+ }
45
+ if (!rest)
46
+ throw new Error(`Plugin spec "${spec}" has no package name`);
47
+ const at = rest.lastIndexOf("@");
48
+ let name = rest;
49
+ let version;
50
+ if (at > 0) {
51
+ name = rest.slice(0, at);
52
+ version = rest.slice(at + 1);
53
+ }
54
+ if (name.length > 214)
55
+ throw new Error(`Invalid plugin spec "${spec}": package name is too long`);
56
+ if (!NAME_RE.test(name) || name.includes(".."))
57
+ throw new Error(`Invalid plugin package name "${name}" in spec "${spec}": allowed characters are letters, ` +
58
+ `digits, dot, underscore and dash, with "/" only for a scoped name and "@" only for a version; ` +
59
+ `names may not start with "." or "_" and may not contain ".."`);
60
+ if (version !== undefined && !VERSION_RE.test(version))
61
+ throw new Error(`Invalid plugin version "${version}" in spec "${spec}": versions may contain only letters, ` +
62
+ `digits, dot, underscore and dash (for example 1.2.3, 1.2.3-beta.1 or latest)`);
63
+ return { name, version };
64
+ }
65
+ /** The npm argument for a spec: `name`, `name@<version>` or `name@latest` for `--update`. */
66
+ export function npmInstallTarget(name, version, update = false) {
67
+ return update ? `${name}@latest` : version ? `${name}@${version}` : name;
68
+ }
69
+ export const INSTALL_TIMEOUT_MS = 300_000;
70
+ const defaultRunner = (command, args, options) => runProcess(command, args, { ...options, timeoutMs: INSTALL_TIMEOUT_MS, maxBytes: 64_000 });
71
+ /**
72
+ * Redacts probable secrets from npm output (auth tokens, JWTs, bearer credentials, userinfo in
73
+ * URLs) and caps the size, so a failing install never echoes credentials to the user.
74
+ */
75
+ export function sanitizeNpmError(text, maxLength = 4000) {
76
+ const MAX = Math.max(1, maxLength);
77
+ let cleaned = text.replace(/(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+|\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*\b|\b(?:npm_?authToken|_authToken|NODE_AUTH_TOKEN|NPM_TOKEN|authToken|password|secret)\b\s*(?:=|:)\s*\S+|\b(?:npm_?authToken|_authToken|NODE_AUTH_TOKEN|NPM_TOKEN|authToken|password|secret)\b\s+\S+)/gi, "[redacted]");
78
+ cleaned = cleaned.replace(/([a-z][a-z0-9+.-]*):\/\/[^@\s/]+@/gi, "$1://[redacted]@");
79
+ if (cleaned.length > MAX)
80
+ cleaned = `${cleaned.slice(0, MAX)}\n…[npm output truncated]`;
81
+ return cleaned;
82
+ }
83
+ function npmInstallError(name, version, update, pluginsDir, result) {
84
+ const detail = sanitizeNpmError([result.stdout, result.stderr].filter(Boolean).join("\n"));
85
+ const retry = `npm install --prefix ${pluginsDir} ${npmInstallTarget(name, version, update)}`;
86
+ return new Error(`npm install failed for plugin "${name}" (exit ${result.exitCode}).\n${detail || "no output"}\n` +
87
+ `Retry with: ${retry}\n(or: alisio install npm:${npmInstallTarget(name, version)})`);
88
+ }
89
+ /** Atomically adds `packageName` to the global configuration `plugins` array, preserving all other fields. */
90
+ async function persistGlobalPluginEntry(configHomeDir, packageName) {
91
+ const file = join(configHomeDir, "config.json");
92
+ const existing = await readJson(file);
93
+ let raw;
94
+ if (existing === undefined)
95
+ raw = {};
96
+ else if (typeof existing === "object" && !Array.isArray(existing))
97
+ raw = existing;
98
+ else
99
+ throw new Error(`Global Alisio configuration must be a JSON object: ${file}`);
100
+ const current = Array.isArray(raw.plugins) ? raw.plugins : [];
101
+ if (!current.includes(packageName))
102
+ raw.plugins = [...current, packageName];
103
+ raw.schemaVersion ??= 1;
104
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
105
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
106
+ const handle = await open(temporary, "wx", 0o600);
107
+ try {
108
+ await handle.writeFile(`${JSON.stringify(raw, null, 2)}\n`, "utf8");
109
+ await handle.sync();
110
+ }
111
+ finally {
112
+ await handle.close();
113
+ }
114
+ try {
115
+ await rename(temporary, file);
116
+ await chmod(file, 0o600);
117
+ }
118
+ catch (error) {
119
+ await unlink(temporary).catch(() => { });
120
+ throw error;
121
+ }
122
+ return file;
123
+ }
124
+ /** The shared install routine used by both the CLI command and the `plugin_install` tool. */
125
+ export async function installPlugin(input) {
126
+ const parsed = parsePluginSpec(input.spec);
127
+ if (input.readOnly)
128
+ throw new Error(`Installing npm plugin "${parsed.name}" is unavailable under --read-only: installation runs npm and writes your global configuration. Run without --read-only.`);
129
+ const home = resolve(input.configHome ?? configHome());
130
+ const pluginsDir = join(home, "plugins");
131
+ // npm creates the prefix directory itself, but the subprocess cwd must already exist for spawn.
132
+ await mkdir(pluginsDir, { recursive: true, mode: 0o700 });
133
+ const packageDir = join(pluginsDir, "node_modules", parsed.name);
134
+ const installedManifest = (await readJson(join(packageDir, "package.json")));
135
+ const alreadyInstalled = !!installedManifest?.version;
136
+ let updated = false;
137
+ if (!alreadyInstalled || input.update) {
138
+ const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
139
+ const run = input.runner ?? defaultRunner;
140
+ const npmResult = await run("npm", [
141
+ "install",
142
+ "--prefix",
143
+ pluginsDir,
144
+ npmInstallTarget(parsed.name, parsed.version, input.update),
145
+ ], { cwd: pluginsDir, signal });
146
+ if (npmResult.exitCode !== 0)
147
+ throw npmInstallError(parsed.name, parsed.version, input.update, pluginsDir, npmResult);
148
+ updated = !!input.update && alreadyInstalled;
149
+ }
150
+ const manifest = (await readJson(join(packageDir, "package.json")));
151
+ const installedVersion = manifest?.version ?? "unknown";
152
+ const configFile = await persistGlobalPluginEntry(home, parsed.name);
153
+ return {
154
+ packageName: parsed.name,
155
+ installedVersion,
156
+ path: packageDir,
157
+ configEntry: parsed.name,
158
+ configFile,
159
+ pluginsDir,
160
+ alreadyInstalled,
161
+ updated,
162
+ trustRemark: pluginTrustRemark(parsed.name),
163
+ };
164
+ }
165
+ /** Final remark about loading and project trust, printed by the CLI and returned by the tool. */
166
+ export function pluginTrustRemark(name) {
167
+ return (`The plugin "${name}" is installed globally for your user; it loads as trusted personal code ` +
168
+ `wherever global plugins load — use \`alisio --trust-project\` or accept the one-time trust prompt ` +
169
+ `in a project so its configuration and project plugins load too. \`--read-only\` keeps the plugin ` +
170
+ `from loading. Run \`alisio plugins list\` to inspect.`);
171
+ }
172
+ const PREINSTALL_WARNING = `Warning: "npm install" may run the package's lifecycle scripts (preinstall, install, postinstall) ` +
173
+ `with your user privileges. Alisio does not sandbox plugin installation or plugin code; the plugin ` +
174
+ `runs in-process if it is loaded. Only install packages you trust.`;
175
+ export async function cliInstall(options) {
176
+ // Validate the spec before any network operation or prompt.
177
+ const parsed = parsePluginSpec(options.spec);
178
+ if (options.readOnly)
179
+ throw new Error(`Refusing to install npm plugin "${parsed.name}" under --read-only: installing runs npm and writes your global configuration. Run the command without --read-only.`);
180
+ if (options.yes) {
181
+ // Explicit opt-in: no warning, no prompt.
182
+ }
183
+ else if (options.interactive) {
184
+ process.stderr.write(`\n${PREINSTALL_WARNING}\n`);
185
+ const confirmed = await (options.confirm ?? defaultConfirm)();
186
+ if (!confirmed)
187
+ throw new Error(`Install of plugin "${parsed.name}" cancelled; nothing was installed or configured.`);
188
+ }
189
+ else {
190
+ // Headless (including --json): never prompt, fail with an actionable error.
191
+ throw new Error(`Refusing to install npm plugin "${parsed.name}" without confirmation: npm install may run ` +
192
+ `lifecycle scripts from the package. Re-run with --yes (or --trust-plugin) to confirm, for ` +
193
+ `example: alisio install npm:${npmInstallTarget(parsed.name, parsed.version)} --yes`);
194
+ }
195
+ const result = await installPlugin({
196
+ spec: options.spec,
197
+ configHome: options.configHome,
198
+ update: options.update,
199
+ signal: options.signal,
200
+ runner: options.runner,
201
+ });
202
+ printInstallResult(result, { json: !!options.json });
203
+ return result;
204
+ }
205
+ async function defaultConfirm() {
206
+ const { createInterface } = await import("node:readline/promises");
207
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
208
+ try {
209
+ const answer = (await rl.question("Install this plugin package globally? [y/N] "))
210
+ .trim()
211
+ .toLowerCase();
212
+ return answer === "y" || answer === "yes";
213
+ }
214
+ finally {
215
+ rl.close();
216
+ }
217
+ }
218
+ export function printInstallResult(result, options = {}) {
219
+ if (options.json) {
220
+ process.stdout.write(`${JSON.stringify({
221
+ packageName: result.packageName,
222
+ installedVersion: result.installedVersion,
223
+ path: result.path,
224
+ configEntry: result.configEntry,
225
+ configFile: result.configFile,
226
+ alreadyInstalled: result.alreadyInstalled,
227
+ updated: result.updated,
228
+ trustRemark: result.trustRemark,
229
+ }, null, 2)}\n`);
230
+ return;
231
+ }
232
+ if (result.alreadyInstalled && !result.updated)
233
+ console.log(`Plugin "${result.packageName}" is already installed (v${result.installedVersion}). Use --update to refresh it to the latest version and keep its name.`);
234
+ else if (result.updated)
235
+ console.log(`Updated plugin "${result.packageName}" to v${result.installedVersion}.`);
236
+ else
237
+ console.log(`Installed plugin "${result.packageName}" v${result.installedVersion}.`);
238
+ console.log(` path: ${result.path}`);
239
+ console.log(` config: "plugins" entry "${result.configEntry}" in ${result.configFile}`);
240
+ console.log(result.trustRemark);
241
+ }
242
+ /**
243
+ * Registers the host-owned `plugin_install` tool (agent capability). It has process effect so it
244
+ * goes through the existing write/process/external permission gate (TUI approval or
245
+ * `--allow-process`; never available under `--read-only`, where it is not registered at all).
246
+ */
247
+ export function registerPluginInstallTool(registry, options = {}) {
248
+ if (options.readOnly)
249
+ return;
250
+ const tool = {
251
+ name: "plugin_install",
252
+ effect: "process",
253
+ description: "Install an npm plugin package into Alisio's global plugins directory " +
254
+ "(<configHome>/plugins, for example ~/.config/alisio/plugins) and add its npm name to the " +
255
+ 'global configuration "plugins" array so it can load across projects. Accepts "npm:<package>' +
256
+ "[@<version>]\" or a bare package name. npm may run the package's lifecycle scripts with your " +
257
+ "privileges, so this tool goes through the process permission gate like any subprocess; the " +
258
+ "installed plugin runs in-process wherever it is loaded and is disabled under --read-only.",
259
+ inputSchema: {
260
+ type: "object",
261
+ properties: { spec: { type: "string", minLength: 1 } },
262
+ required: ["spec"],
263
+ additionalProperties: false,
264
+ },
265
+ async execute(input, context) {
266
+ const result = await installPlugin({
267
+ spec: String(input.spec),
268
+ configHome: options.configHome,
269
+ runner: options.runner,
270
+ signal: context.signal,
271
+ });
272
+ return textResult(JSON.stringify({
273
+ packageName: result.packageName,
274
+ installedVersion: result.installedVersion,
275
+ path: result.path,
276
+ configEntry: result.configEntry,
277
+ configFile: result.configFile,
278
+ alreadyInstalled: result.alreadyInstalled,
279
+ updated: result.updated,
280
+ trustRemark: result.trustRemark,
281
+ }));
282
+ },
283
+ };
284
+ registry.register(tool);
285
+ }
286
+ /**
287
+ * Lists npm packages installed under `<root>/node_modules` that declare the `alisio-plugin`
288
+ * keyword — the same packages `plugins`-array entries resolve to. Used by `alisio plugins list`
289
+ * so an installed package shows up alongside file/directory plugins.
290
+ */
291
+ export async function installedNpmPlugins(root) {
292
+ const packagesDir = join(root, "node_modules");
293
+ let entries;
294
+ try {
295
+ entries = await readdir(packagesDir, { withFileTypes: true });
296
+ }
297
+ catch (e) {
298
+ if (e.code === "ENOENT")
299
+ return [];
300
+ throw e;
301
+ }
302
+ const result = [];
303
+ const consider = async (dir) => {
304
+ const manifest = (await readJson(join(dir, "package.json")));
305
+ if (manifest && Array.isArray(manifest.keywords) && manifest.keywords.includes(PLUGIN_KEYWORD))
306
+ result.push(dir);
307
+ };
308
+ for (const entry of entries) {
309
+ if (!entry.isDirectory() || entry.name.startsWith("."))
310
+ continue;
311
+ if (entry.name.startsWith("@")) {
312
+ const scoped = await readdir(join(packagesDir, entry.name), { withFileTypes: true }).catch(() => []);
313
+ for (const scope of scoped)
314
+ if (scope.isDirectory())
315
+ await consider(join(packagesDir, entry.name, scope.name));
316
+ }
317
+ else {
318
+ await consider(join(packagesDir, entry.name));
319
+ }
320
+ }
321
+ return result.sort();
322
+ }
@@ -0,0 +1,14 @@
1
+ import type { ModelInfo, ModelProvider, ProviderEvent } from "@alisio/sdk";
2
+ import OpenAI from "openai";
3
+ import type { Config } from "../config.ts";
4
+ export declare class OpenAICompatibleProvider implements ModelProvider {
5
+ private config;
6
+ readonly id: string;
7
+ readonly model: string;
8
+ private client;
9
+ constructor(config: Config["provider"], client?: OpenAI);
10
+ /** Lists models from GET /models. Context window fields are provider extensions. */
11
+ listModels(signal: AbortSignal): Promise<ModelInfo[]>;
12
+ stream(request: Parameters<ModelProvider["stream"]>[0]): AsyncIterable<ProviderEvent>;
13
+ private responses;
14
+ }
@@ -0,0 +1,228 @@
1
+ import OpenAI from "openai";
2
+ const dataUrl = (a) => `data:${a.mimeType};base64,${a.data}`;
3
+ export class OpenAICompatibleProvider {
4
+ config;
5
+ id;
6
+ model;
7
+ client;
8
+ constructor(config, client) {
9
+ this.config = config;
10
+ this.model = config.model;
11
+ if (!this.model)
12
+ throw new Error("Set ALISIO_MODEL, --model, or provider.model");
13
+ const key = config.auth === "none" ? "unused" : process.env[config.apiKeyEnv];
14
+ if (!client && !key)
15
+ throw new Error(`Missing API key environment variable: ${config.apiKeyEnv}`);
16
+ this.id = `openai-compatible:${config.apiMode}:${config.baseURL.replace(/\/$/, "")}`;
17
+ this.client =
18
+ client ??
19
+ new OpenAI({
20
+ baseURL: config.baseURL,
21
+ apiKey: key,
22
+ maxRetries: 0,
23
+ timeout: 120_000,
24
+ ...(config.auth === "none" ? { defaultHeaders: { Authorization: null } } : {}),
25
+ });
26
+ }
27
+ /** Lists models from GET /models. Context window fields are provider extensions. */
28
+ async listModels(signal) {
29
+ const models = [];
30
+ for await (const m of this.client.models.list({ signal })) {
31
+ const extra = m;
32
+ const window = [extra.context_window, extra.context_length, extra.max_context_length].find((v) => typeof v === "number" && Number.isFinite(v) && v > 0);
33
+ models.push({ id: m.id, ...(window ? { contextWindow: window } : {}) });
34
+ if (models.length >= 1000)
35
+ break;
36
+ }
37
+ return models;
38
+ }
39
+ async *stream(request) {
40
+ if (this.config.apiMode === "responses") {
41
+ yield* this.responses(request);
42
+ return;
43
+ }
44
+ const messages = [
45
+ { role: "system", content: request.instructions },
46
+ ];
47
+ for (const m of request.messages) {
48
+ if (m.role === "user") {
49
+ if (m.attachments?.length) {
50
+ const parts = [];
51
+ if (m.text.trim())
52
+ parts.push({ type: "text", text: m.text });
53
+ for (const a of m.attachments)
54
+ parts.push({ type: "image_url", image_url: { url: dataUrl(a) } });
55
+ messages.push({ role: "user", content: parts });
56
+ }
57
+ else
58
+ messages.push({ role: "user", content: m.text });
59
+ }
60
+ else if (m.role === "tool")
61
+ messages.push({ role: "tool", tool_call_id: m.callId, content: JSON.stringify(m.result) });
62
+ else
63
+ messages.push({
64
+ role: "assistant",
65
+ content: m.text || null,
66
+ ...(m.calls.length
67
+ ? {
68
+ tool_calls: m.calls.map((c) => ({
69
+ id: c.id,
70
+ type: "function",
71
+ function: { name: c.name, arguments: c.arguments },
72
+ })),
73
+ }
74
+ : {}),
75
+ });
76
+ }
77
+ const limit = this.config.tokenParameter === "omit"
78
+ ? {}
79
+ : { [this.config.tokenParameter]: request.maxOutputTokens };
80
+ const stream = await this.client.chat.completions.create({
81
+ model: request.model || this.model,
82
+ messages,
83
+ stream: true,
84
+ ...limit,
85
+ ...(this.config.streamUsage ? { stream_options: { include_usage: true } } : {}),
86
+ ...(request.tools.length || request.nativeTools?.length
87
+ ? {
88
+ tools: [
89
+ ...request.tools.map((t) => ({
90
+ type: "function",
91
+ function: { name: t.name, description: t.description, parameters: t.inputSchema },
92
+ })),
93
+ ...(request.nativeTools ?? []),
94
+ ],
95
+ }
96
+ : {}),
97
+ }, { signal: request.signal });
98
+ let text = "", finish = null, usage;
99
+ const calls = new Map();
100
+ for await (const chunk of stream) {
101
+ if (chunk.usage) {
102
+ // DeepSeek reports prompt_cache_hit_tokens; OpenAI uses prompt_tokens_details.
103
+ const extra = chunk.usage;
104
+ const cached = chunk.usage.prompt_tokens_details?.cached_tokens ?? extra.prompt_cache_hit_tokens;
105
+ usage = {
106
+ input: chunk.usage.prompt_tokens,
107
+ output: chunk.usage.completion_tokens,
108
+ ...(typeof cached === "number" ? { cachedInput: cached } : {}),
109
+ };
110
+ }
111
+ const choice = chunk.choices[0];
112
+ if (!choice)
113
+ continue;
114
+ const reasoning = choice.delta.reasoning_content;
115
+ if (typeof reasoning === "string" && reasoning)
116
+ yield { type: "reasoning_delta", delta: reasoning };
117
+ if (choice.delta.content) {
118
+ text += choice.delta.content;
119
+ yield { type: "text_delta", delta: choice.delta.content };
120
+ }
121
+ if (choice.delta.refusal)
122
+ throw new Error(`Provider refusal: ${choice.delta.refusal}`);
123
+ for (const part of choice.delta.tool_calls ?? []) {
124
+ const c = calls.get(part.index) ?? { id: "", name: "", arguments: "" };
125
+ if (part.id)
126
+ c.id = part.id;
127
+ if (part.function?.name)
128
+ c.name += part.function.name;
129
+ if (part.function?.arguments)
130
+ c.arguments += part.function.arguments;
131
+ calls.set(part.index, c);
132
+ }
133
+ if (choice.finish_reason)
134
+ finish = choice.finish_reason;
135
+ }
136
+ if (finish !== "stop" && finish !== "tool_calls")
137
+ throw new Error(`Provider response incomplete: ${finish ?? "stream ended"}`);
138
+ const completed = [...calls.entries()].sort(([a], [b]) => a - b).map(([, c]) => c);
139
+ if (completed.some((c) => !c.id || !c.name))
140
+ throw new Error("Incomplete tool call");
141
+ yield { type: "completed", message: { role: "assistant", text, calls: completed }, usage };
142
+ }
143
+ async *responses(request) {
144
+ const input = [];
145
+ for (const m of request.messages) {
146
+ if (m.role === "user") {
147
+ if (m.attachments?.length) {
148
+ const content = [];
149
+ if (m.text.trim())
150
+ content.push({ type: "input_text", text: m.text });
151
+ for (const a of m.attachments)
152
+ content.push({ type: "input_image", image_url: dataUrl(a), detail: "auto" });
153
+ input.push({ role: "user", content });
154
+ }
155
+ else
156
+ input.push({ role: "user", content: m.text });
157
+ }
158
+ else if (m.role === "tool")
159
+ input.push({
160
+ type: "function_call_output",
161
+ call_id: m.callId,
162
+ output: JSON.stringify(m.result),
163
+ });
164
+ else if (m.providerData)
165
+ input.push(...m.providerData);
166
+ else
167
+ throw new Error("Missing provider continuation data for Responses session");
168
+ }
169
+ const stream = await this.client.responses.create({
170
+ model: request.model || this.model,
171
+ instructions: request.instructions,
172
+ input,
173
+ stream: true,
174
+ store: false,
175
+ include: ["reasoning.encrypted_content"],
176
+ max_output_tokens: request.maxOutputTokens,
177
+ tools: [
178
+ ...request.tools.map((t) => ({
179
+ type: "function",
180
+ name: t.name,
181
+ description: t.description,
182
+ parameters: t.inputSchema,
183
+ strict: false,
184
+ })),
185
+ ...(request.nativeTools ?? []),
186
+ ],
187
+ }, { signal: request.signal });
188
+ let complete = false;
189
+ for await (const event of stream) {
190
+ if (event.type === "response.output_text.delta")
191
+ yield { type: "text_delta", delta: event.delta };
192
+ if (event.type === "response.reasoning_summary_text.delta")
193
+ yield { type: "reasoning_delta", delta: event.delta };
194
+ if (event.type === "response.failed" ||
195
+ event.type === "response.incomplete" ||
196
+ event.type === "error")
197
+ throw new Error(`Provider response failed: ${event.type}`);
198
+ if (event.type === "response.completed") {
199
+ complete = true;
200
+ const r = event.response;
201
+ const calls = r.output
202
+ .filter((x) => x.type === "function_call")
203
+ .map((x) => ({ id: x.call_id, name: x.name, arguments: x.arguments }));
204
+ const text = r.output
205
+ .filter((x) => x.type === "message")
206
+ .flatMap((x) => x.content)
207
+ .filter((x) => x.type === "output_text")
208
+ .map((x) => x.text)
209
+ .join("");
210
+ yield {
211
+ type: "completed",
212
+ message: { role: "assistant", text, calls, providerData: r.output },
213
+ usage: r.usage
214
+ ? {
215
+ input: r.usage.input_tokens,
216
+ output: r.usage.output_tokens,
217
+ ...(typeof r.usage.input_tokens_details?.cached_tokens === "number"
218
+ ? { cachedInput: r.usage.input_tokens_details.cached_tokens }
219
+ : {}),
220
+ }
221
+ : undefined,
222
+ };
223
+ }
224
+ }
225
+ if (!complete)
226
+ throw new Error("Responses stream ended before completion");
227
+ }
228
+ }
@@ -0,0 +1,35 @@
1
+ import type { ModelInfo, ModelProvider, ProviderCreateRequest, ProviderRegistration } from "@alisio/sdk";
2
+ export interface RegisteredProvider extends ProviderRegistration {
3
+ plugin: string;
4
+ builtin: boolean;
5
+ }
6
+ /** Additive provider catalog. Registrations are unique by provider id and independently removable. */
7
+ export declare class ProviderRegistry {
8
+ private entries;
9
+ register(registration: ProviderRegistration, owner: {
10
+ plugin: string;
11
+ builtin: boolean;
12
+ }): () => void;
13
+ get(id: string): RegisteredProvider | undefined;
14
+ list(): RegisteredProvider[];
15
+ create(id: string, request: ProviderCreateRequest): Promise<ModelProvider>;
16
+ }
17
+ /** Mutable facade keeps runner/plugin completion references stable while the active provider changes. */
18
+ export declare class ActiveProvider implements ModelProvider {
19
+ private current;
20
+ constructor(current: ModelProvider);
21
+ /** Core-only lifecycle access; credentials remain encapsulated by the provider instance. */
22
+ get currentProvider(): ModelProvider;
23
+ get id(): string;
24
+ get model(): string;
25
+ stream(request: Parameters<ModelProvider["stream"]>[0]): AsyncIterable<import("@alisio/sdk").ProviderEvent>;
26
+ listModels(signal: AbortSignal): Promise<ModelInfo[]>;
27
+ /** Swap only after the candidate exists; a failed candidate creation leaves the old provider live. */
28
+ replace(next: ModelProvider, disposePrevious?: boolean): Promise<void>;
29
+ dispose(): void | Promise<void> | undefined;
30
+ }
31
+ export declare class UnconfiguredProvider implements ModelProvider {
32
+ readonly id = "unconfigured";
33
+ readonly model = "";
34
+ stream(): AsyncIterable<never>;
35
+ }
@@ -0,0 +1,74 @@
1
+ /** Additive provider catalog. Registrations are unique by provider id and independently removable. */
2
+ export class ProviderRegistry {
3
+ entries = new Map();
4
+ register(registration, owner) {
5
+ if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(registration.id))
6
+ throw new Error(`Invalid provider id: ${registration.id}`);
7
+ if (this.entries.has(registration.id))
8
+ throw new Error(`Duplicate provider: ${registration.id}`);
9
+ const entry = { ...registration, ...owner };
10
+ this.entries.set(entry.id, entry);
11
+ return () => {
12
+ if (this.entries.get(entry.id) === entry)
13
+ this.entries.delete(entry.id);
14
+ };
15
+ }
16
+ get(id) {
17
+ return this.entries.get(id);
18
+ }
19
+ list() {
20
+ return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name));
21
+ }
22
+ async create(id, request) {
23
+ const registration = this.entries.get(id);
24
+ if (!registration)
25
+ throw new Error(`Unknown provider: ${id}`);
26
+ return registration.create(request);
27
+ }
28
+ }
29
+ /** Mutable facade keeps runner/plugin completion references stable while the active provider changes. */
30
+ export class ActiveProvider {
31
+ current;
32
+ constructor(current) {
33
+ this.current = current;
34
+ }
35
+ /** Core-only lifecycle access; credentials remain encapsulated by the provider instance. */
36
+ get currentProvider() {
37
+ return this.current;
38
+ }
39
+ get id() {
40
+ return this.current.id;
41
+ }
42
+ get model() {
43
+ return this.current.model;
44
+ }
45
+ stream(request) {
46
+ return this.current.stream(request);
47
+ }
48
+ listModels(signal) {
49
+ return this.current.listModels?.(signal) ?? Promise.resolve([]);
50
+ }
51
+ /** Swap only after the candidate exists; a failed candidate creation leaves the old provider live. */
52
+ async replace(next, disposePrevious = true) {
53
+ const previous = this.current;
54
+ this.current = next;
55
+ if (!disposePrevious)
56
+ return;
57
+ try {
58
+ await previous.dispose?.();
59
+ }
60
+ catch {
61
+ // Activation already committed. A stale provider disposal failure must not roll it back.
62
+ }
63
+ }
64
+ dispose() {
65
+ return this.current.dispose?.();
66
+ }
67
+ }
68
+ export class UnconfiguredProvider {
69
+ id = "unconfigured";
70
+ model = "";
71
+ async *stream() {
72
+ throw new Error("No model provider is configured. Use /connect in the TUI or configure one before running headless.");
73
+ }
74
+ }