@alisio/core 0.2.1 → 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.
@@ -1,7 +1,9 @@
1
- import type { AskQuestionsRequest, AskQuestionsResult, CommandContext, CommandOptions, CompletionRequest, Message, PanelProvider, Plugin, PluginAPI, PluginMetadata, RunEvent, SelectRequest, ViewContext } from "@alisio/sdk";
1
+ import type { AskQuestionsRequest, AskQuestionsResult, CommandContext, CommandOptions, CompletionRequest, JsonValue, Message, PanelProvider, Plugin, PluginAPI, PluginMetadata, RunEvent, SelectRequest, ViewContext } from "@alisio/sdk";
2
2
  import type { HookFailure, RunnerExtensions } from "../core/contracts.ts";
3
3
  import type { HumanWaits } from "../core/human-wait.ts";
4
4
  import type { ToolRegistry } from "../core/registry.ts";
5
+ import type { DecisionRegistry } from "../decisions/registry.ts";
6
+ import type { DecisionService } from "../decisions/service.ts";
5
7
  import { ExtensionRegistry } from "../extensions/registry.ts";
6
8
  import type { ProviderRegistry } from "../providers/registry.ts";
7
9
  export declare const pluginPrefix: (id: string) => string;
@@ -10,6 +12,16 @@ export interface PluginHostOptions {
10
12
  hookTimeoutMs?: number;
11
13
  /** Timeout for session-end hooks (for example an automatic summary). */
12
14
  sessionEndTimeoutMs?: number;
15
+ /** Per-plugin limit for `dispose()` on close (default 2000 ms). */
16
+ disposeTimeoutMs?: number;
17
+ }
18
+ /** What the host needs to hand each plugin its `api.paths` and `api.options`. */
19
+ export interface PluginEnvironment {
20
+ /** Root of the application state (the one analysis and artifacts use). */
21
+ stateRoot: string;
22
+ configHome: string;
23
+ /** `pluginOverrides[id].options` (external) or `builtinPlugins.<id>` without `enabled`. */
24
+ options(id: string, builtin: boolean): Record<string, JsonValue>;
13
25
  }
14
26
  /** A failed data view: the host maps `code` to an API error (never echoing parameter values). */
15
27
  export declare class ViewRunError extends Error {
@@ -132,6 +144,21 @@ export declare class PluginHost implements RunnerExtensions {
132
144
  setCompleter(fn: (request: CompletionRequest & {
133
145
  signal: AbortSignal;
134
146
  }) => Promise<string>): void;
147
+ /**
148
+ * Binds Decision Intelligence. Plugins activated afterwards see `api.decisions`; without it the
149
+ * member is absent, exactly like on a core that predates the feature.
150
+ */
151
+ setDecisions(decisions: {
152
+ registry: DecisionRegistry;
153
+ service: DecisionService;
154
+ }): void;
155
+ private decisionsImpl?;
156
+ /**
157
+ * Binds the directories and options plugins receive (`api.paths`, `api.options`). Plugins
158
+ * activated before this call, or on a host without it, see neither member.
159
+ */
160
+ setPluginEnvironment(environment: PluginEnvironment): void;
161
+ private environment?;
135
162
  /** Binds credential-free model discovery/resolution after provider startup is complete. */
136
163
  setModels(models: PluginAPI["models"]): void;
137
164
  /**
@@ -206,6 +233,11 @@ export declare class PluginHost implements RunnerExtensions {
206
233
  /** Conservative ownership check used to protect a live session from plugin removal. */
207
234
  ownsSessionResources(plugin: string): boolean;
208
235
  emit(event: RunEvent): void;
236
+ /**
237
+ * Disposes every plugin in parallel, each bounded by `disposeTimeoutMs`: a slow or hung
238
+ * `dispose()` neither delays the others nor holds the close past its limit. Registrations are
239
+ * undone either way. Failures (including a timeout) are reported together afterwards.
240
+ */
209
241
  close(): Promise<void>;
210
242
  }
211
243
  export declare function discoverPlugins(root: string): Promise<string[]>;
@@ -7,17 +7,26 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { createHash } from "node:crypto";
10
+ import { mkdirSync } from "node:fs";
10
11
  import { readdir, stat } from "node:fs/promises";
11
12
  import { dirname, join, resolve } from "node:path";
12
13
  import { pathToFileURL } from "node:url";
13
14
  import Ajv from "ajv";
14
15
  import { z } from "zod";
15
16
  import { ExtensionRegistry } from "../extensions/registry.js";
16
- import { readJson } from "../runtime/fs.js";
17
+ import { exists, readJson } from "../runtime/fs.js";
17
18
  import { resolvePluginSpec } from "../runtime/modules.js";
18
19
  import { inside } from "../runtime/paths.js";
19
20
  import { openDatabase } from "../runtime/sqlite.js";
20
21
  export const pluginPrefix = (id) => `p_${createHash("sha256").update(id).digest("hex").slice(0, 10)}`;
22
+ const deepFreeze = (value) => {
23
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
24
+ Object.freeze(value);
25
+ for (const child of Object.values(value))
26
+ deepFreeze(child);
27
+ }
28
+ return value;
29
+ };
21
30
  const VIEW_ID = /^[a-z][a-z0-9-]{0,39}$/;
22
31
  const VIEW_PARAM_TYPES = new Set(["string", "integer", "number", "boolean"]);
23
32
  /** Query strings arrive as text: coerce them to the declared primitives and apply defaults. */
@@ -156,6 +165,22 @@ export class PluginHost {
156
165
  setCompleter(fn) {
157
166
  this.completer = fn;
158
167
  }
168
+ /**
169
+ * Binds Decision Intelligence. Plugins activated afterwards see `api.decisions`; without it the
170
+ * member is absent, exactly like on a core that predates the feature.
171
+ */
172
+ setDecisions(decisions) {
173
+ this.decisionsImpl = decisions;
174
+ }
175
+ decisionsImpl;
176
+ /**
177
+ * Binds the directories and options plugins receive (`api.paths`, `api.options`). Plugins
178
+ * activated before this call, or on a host without it, see neither member.
179
+ */
180
+ setPluginEnvironment(environment) {
181
+ this.environment = environment;
182
+ }
183
+ environment;
159
184
  /** Binds credential-free model discovery/resolution after provider startup is complete. */
160
185
  setModels(models) {
161
186
  this.modelsImpl = models;
@@ -218,6 +243,7 @@ export class PluginHost {
218
243
  "mcp",
219
244
  "storage",
220
245
  "ui",
246
+ "decisions",
221
247
  ]))
222
248
  .optional(),
223
249
  setup: z.function(),
@@ -376,6 +402,42 @@ export class PluginHost {
376
402
  });
377
403
  },
378
404
  },
405
+ ...(this.environment
406
+ ? (({ stateRoot, configHome, options }) => {
407
+ // The id is validated (`^[a-z0-9][a-z0-9.-]{0,63}$`): it cannot be `.`/`..` or hold a
408
+ // separator, so these stay inside their roots. Created 0700 on first read.
409
+ const state = join(stateRoot, "plugins", plugin.id);
410
+ const directories = {
411
+ state,
412
+ config: join(configHome, "plugins", plugin.id),
413
+ cache: join(state, "cache"),
414
+ };
415
+ const paths = {};
416
+ for (const key of ["state", "config", "cache"])
417
+ Object.defineProperty(paths, key, {
418
+ enumerable: true,
419
+ get: () => {
420
+ mkdirSync(directories[key], { recursive: true, mode: 0o700 });
421
+ return directories[key];
422
+ },
423
+ });
424
+ return {
425
+ paths: Object.freeze(paths),
426
+ options: deepFreeze(structuredClone(options(plugin.id, builtin))),
427
+ };
428
+ })(this.environment)
429
+ : {}),
430
+ ...(this.decisionsImpl
431
+ ? {
432
+ decisions: (({ registry, service }) => ({
433
+ // Registering never activates: `decisions.provider` in the configuration does.
434
+ registerProvider: (provider) => track(registry.register(plugin.id, provider)),
435
+ available: () => service.available(),
436
+ activeProvider: () => service.activeProvider(),
437
+ tryDecide: (request, options) => service.tryDecide(request, options),
438
+ }))(this.decisionsImpl),
439
+ }
440
+ : {}),
379
441
  compaction: {
380
442
  register: (hooks) => {
381
443
  const entry = { plugin: plugin.id, hooks };
@@ -690,23 +752,34 @@ export class PluginHost {
690
752
  }
691
753
  }
692
754
  }
755
+ /**
756
+ * Disposes every plugin in parallel, each bounded by `disposeTimeoutMs`: a slow or hung
757
+ * `dispose()` neither delays the others nor holds the close past its limit. Registrations are
758
+ * undone either way. Failures (including a timeout) are reported together afterwards.
759
+ */
693
760
  async close() {
694
- const errors = [];
695
- for (const { plugin, undo } of [...this.loaded.values()].reverse()) {
761
+ const limit = this.options.disposeTimeoutMs ?? 2000;
762
+ const entries = [...this.loaded.entries()].reverse();
763
+ this.loaded.clear();
764
+ this.externalCount = 0;
765
+ this.builtins.clear();
766
+ const results = await Promise.allSettled(entries.map(async ([id, { plugin, undo }]) => {
767
+ let timer;
696
768
  try {
697
- await plugin.dispose?.();
698
- }
699
- catch (e) {
700
- errors.push(e);
769
+ await Promise.race([
770
+ Promise.resolve().then(() => plugin.dispose?.()),
771
+ new Promise((_, reject) => {
772
+ timer = setTimeout(() => reject(new Error(`Plugin ${id} dispose timed out after ${limit}ms`)), limit);
773
+ }),
774
+ ]);
701
775
  }
702
776
  finally {
777
+ clearTimeout(timer);
703
778
  for (const fn of undo.reverse())
704
779
  fn();
705
780
  }
706
- }
707
- this.loaded.clear();
708
- this.externalCount = 0;
709
- this.builtins.clear();
781
+ }));
782
+ const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
710
783
  if (errors.length)
711
784
  throw new AggregateError(errors, "Plugin disposal failed");
712
785
  }
@@ -721,7 +794,7 @@ export async function discoverPlugins(root) {
721
794
  return [];
722
795
  throw e;
723
796
  }
724
- return entries
797
+ const found = await Promise.all(entries
725
798
  .filter(
726
799
  // A root may hold an `npm install --prefix` layout (`node_modules`, package.json/lock);
727
800
  // npm packages there are resolved through the configured `plugins` array instead.
@@ -729,6 +802,13 @@ export async function discoverPlugins(root) {
729
802
  e.name !== "package.json" &&
730
803
  e.name !== "package-lock.json" &&
731
804
  ((e.isFile() && /\.[cm]?[jt]s$/.test(e.name)) || e.isDirectory()))
732
- .map((e) => join(root, e.name))
733
- .sort();
805
+ .map(async (e) => {
806
+ const path = join(root, e.name);
807
+ // A directory is a plugin only with its manifest: `<config home>/plugins/<id>` is also
808
+ // where `api.paths.config` puts a plugin's own files, and those are not plugins.
809
+ if (e.isDirectory() && !(await exists(join(path, "alisio-plugin.json"))))
810
+ return undefined;
811
+ return path;
812
+ }));
813
+ return found.filter((path) => path !== undefined).sort();
734
814
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "node": ">=22.16"
42
42
  },
43
43
  "dependencies": {
44
- "@alisio/sdk": "0.2.1",
44
+ "@alisio/sdk": "0.3.0",
45
45
  "@mixmark-io/domino": "2.2.0",
46
46
  "@modelcontextprotocol/client": "2.1.0",
47
47
  "ajv": "8.20.0",