@alisio/core 0.2.0 → 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.
Files changed (40) hide show
  1. package/dist/application.d.ts +7 -0
  2. package/dist/application.js +90 -6
  3. package/dist/changelog/data.js +36 -0
  4. package/dist/commands/builtins.js +6 -0
  5. package/dist/commands/catalog.d.ts +8 -1
  6. package/dist/commands/catalog.js +62 -0
  7. package/dist/config.d.ts +37 -3
  8. package/dist/config.js +109 -7
  9. package/dist/core/contracts.d.ts +10 -1
  10. package/dist/core/human-wait.d.ts +25 -0
  11. package/dist/core/human-wait.js +54 -0
  12. package/dist/core/run-clock.d.ts +47 -0
  13. package/dist/core/run-clock.js +111 -0
  14. package/dist/core/run-stats.d.ts +2 -0
  15. package/dist/core/runner.d.ts +15 -1
  16. package/dist/core/runner.js +70 -18
  17. package/dist/core/timeout.js +2 -2
  18. package/dist/decisions/bound.d.ts +23 -0
  19. package/dist/decisions/bound.js +48 -0
  20. package/dist/decisions/breaker.d.ts +35 -0
  21. package/dist/decisions/breaker.js +61 -0
  22. package/dist/decisions/format.d.ts +15 -0
  23. package/dist/decisions/format.js +89 -0
  24. package/dist/decisions/index.d.ts +7 -0
  25. package/dist/decisions/index.js +7 -0
  26. package/dist/decisions/metrics.d.ts +45 -0
  27. package/dist/decisions/metrics.js +79 -0
  28. package/dist/decisions/registry.d.ts +22 -0
  29. package/dist/decisions/registry.js +64 -0
  30. package/dist/decisions/service.d.ts +137 -0
  31. package/dist/decisions/service.js +377 -0
  32. package/dist/decisions/validate.d.ts +26 -0
  33. package/dist/decisions/validate.js +182 -0
  34. package/dist/goal/host.d.ts +2 -0
  35. package/dist/goal/host.js +2 -1
  36. package/dist/index.d.ts +5 -2
  37. package/dist/index.js +3 -0
  38. package/dist/plugins/host.d.ts +43 -2
  39. package/dist/plugins/host.js +111 -16
  40. package/package.json +2 -2
@@ -1,6 +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
+ import type { HumanWaits } from "../core/human-wait.ts";
3
4
  import type { ToolRegistry } from "../core/registry.ts";
5
+ import type { DecisionRegistry } from "../decisions/registry.ts";
6
+ import type { DecisionService } from "../decisions/service.ts";
4
7
  import { ExtensionRegistry } from "../extensions/registry.ts";
5
8
  import type { ProviderRegistry } from "../providers/registry.ts";
6
9
  export declare const pluginPrefix: (id: string) => string;
@@ -9,6 +12,16 @@ export interface PluginHostOptions {
9
12
  hookTimeoutMs?: number;
10
13
  /** Timeout for session-end hooks (for example an automatic summary). */
11
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>;
12
25
  }
13
26
  /** A failed data view: the host maps `code` to an API error (never echoing parameter values). */
14
27
  export declare class ViewRunError extends Error {
@@ -83,7 +96,15 @@ export declare class PluginHost implements RunnerExtensions {
83
96
  private uiImpl?;
84
97
  /** Binds the child session service once the runner exists. */
85
98
  setSessions(sessions: PluginAPI["sessions"]): void;
86
- /** Binds interactive UI services (the TUI); without them select/askQuestions resolve undefined. */
99
+ /** Where waits for a person are opened (see `HumanWaits`); bound once the runner exists. */
100
+ private humanWaits?;
101
+ setHumanWaits(waits: HumanWaits): void;
102
+ /**
103
+ * Binds interactive UI services (the TUI, the web server); without them select/askQuestions
104
+ * resolve undefined. Every question that reaches the person through here (plugins, the
105
+ * `ask_user_question` tool, the plan review) is a human wait: the bound UI is wrapped ONCE so no
106
+ * host and no caller can forget to mark it.
107
+ */
87
108
  setInteractiveUI(ui: {
88
109
  select(request: SelectRequest): Promise<string | undefined>;
89
110
  askQuestions(request: AskQuestionsRequest): Promise<AskQuestionsResult>;
@@ -123,6 +144,21 @@ export declare class PluginHost implements RunnerExtensions {
123
144
  setCompleter(fn: (request: CompletionRequest & {
124
145
  signal: AbortSignal;
125
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?;
126
162
  /** Binds credential-free model discovery/resolution after provider startup is complete. */
127
163
  setModels(models: PluginAPI["models"]): void;
128
164
  /**
@@ -197,6 +233,11 @@ export declare class PluginHost implements RunnerExtensions {
197
233
  /** Conservative ownership check used to protect a live session from plugin removal. */
198
234
  ownsSessionResources(plugin: string): boolean;
199
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
+ */
200
241
  close(): Promise<void>;
201
242
  }
202
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. */
@@ -85,9 +94,24 @@ export class PluginHost {
85
94
  setSessions(sessions) {
86
95
  this.sessionsImpl = sessions;
87
96
  }
88
- /** Binds interactive UI services (the TUI); without them select/askQuestions resolve undefined. */
97
+ /** Where waits for a person are opened (see `HumanWaits`); bound once the runner exists. */
98
+ humanWaits;
99
+ setHumanWaits(waits) {
100
+ this.humanWaits = waits;
101
+ }
102
+ /**
103
+ * Binds interactive UI services (the TUI, the web server); without them select/askQuestions
104
+ * resolve undefined. Every question that reaches the person through here (plugins, the
105
+ * `ask_user_question` tool, the plan review) is a human wait: the bound UI is wrapped ONCE so no
106
+ * host and no caller can forget to mark it.
107
+ */
89
108
  setInteractiveUI(ui) {
90
- this.uiImpl = ui;
109
+ const wait = (session, ask) => this.humanWaits ? this.humanWaits.track(session, ask) : ask();
110
+ this.uiImpl = {
111
+ select: (request) => wait(undefined, () => ui.select(request)),
112
+ askQuestions: (request) => wait(request.session, () => ui.askQuestions(request)),
113
+ open: (sessionId) => ui.open(sessionId),
114
+ };
91
115
  }
92
116
  /**
93
117
  * Read-only view of one plugin's shared state (written through `api.state.set`). The host layer
@@ -141,6 +165,22 @@ export class PluginHost {
141
165
  setCompleter(fn) {
142
166
  this.completer = fn;
143
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;
144
184
  /** Binds credential-free model discovery/resolution after provider startup is complete. */
145
185
  setModels(models) {
146
186
  this.modelsImpl = models;
@@ -203,6 +243,7 @@ export class PluginHost {
203
243
  "mcp",
204
244
  "storage",
205
245
  "ui",
246
+ "decisions",
206
247
  ]))
207
248
  .optional(),
208
249
  setup: z.function(),
@@ -361,6 +402,42 @@ export class PluginHost {
361
402
  });
362
403
  },
363
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
+ : {}),
364
441
  compaction: {
365
442
  register: (hooks) => {
366
443
  const entry = { plugin: plugin.id, hooks };
@@ -675,23 +752,34 @@ export class PluginHost {
675
752
  }
676
753
  }
677
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
+ */
678
760
  async close() {
679
- const errors = [];
680
- 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;
681
768
  try {
682
- await plugin.dispose?.();
683
- }
684
- catch (e) {
685
- 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
+ ]);
686
775
  }
687
776
  finally {
777
+ clearTimeout(timer);
688
778
  for (const fn of undo.reverse())
689
779
  fn();
690
780
  }
691
- }
692
- this.loaded.clear();
693
- this.externalCount = 0;
694
- this.builtins.clear();
781
+ }));
782
+ const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
695
783
  if (errors.length)
696
784
  throw new AggregateError(errors, "Plugin disposal failed");
697
785
  }
@@ -706,7 +794,7 @@ export async function discoverPlugins(root) {
706
794
  return [];
707
795
  throw e;
708
796
  }
709
- return entries
797
+ const found = await Promise.all(entries
710
798
  .filter(
711
799
  // A root may hold an `npm install --prefix` layout (`node_modules`, package.json/lock);
712
800
  // npm packages there are resolved through the configured `plugins` array instead.
@@ -714,6 +802,13 @@ export async function discoverPlugins(root) {
714
802
  e.name !== "package.json" &&
715
803
  e.name !== "package-lock.json" &&
716
804
  ((e.isFile() && /\.[cm]?[jt]s$/.test(e.name)) || e.isDirectory()))
717
- .map((e) => join(root, e.name))
718
- .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();
719
814
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.2.0",
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.0",
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",