@matatbread/matbot-core 0.4.4 → 0.4.6

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
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.4",
7
+ "version": "0.4.6",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.4"
38
+ "@matatbread/matbot-plugin-api": "^0.4.6"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
29
29
  createBroadcaster, subscribable, createNotifier, scopedNotifier,
30
30
  installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
31
- contextSwitch, machineBusy, quiesced, onContextQuiesce, flushIfQuiescent,
31
+ contextSwitch, machineBusy, quiesced, onContextQuiesce, scheduleAtEdge,
32
32
  installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
33
33
  currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
34
34
  } from '@matatbread/matbot-plugin-api/host';
package/src/loader.ts CHANGED
@@ -62,8 +62,17 @@ let freshSeq = 0;
62
62
  * - `name` — the canonical plugin name, when the host has already derived it (e.g. by
63
63
  * reading the resolved package.json). Bypasses `resolver.identify(spec)` — necessary
64
64
  * because `spec` (a remote URL, a versioned npm range) may not itself identify the name.
65
+ * - `version` — the package.json `version`, when the host has already read it. Same reason as
66
+ * `name`: `resolver.version(spec)` starts from `spec`, which for a bare name, a URL or a
67
+ * version range locates no manifest at all.
65
68
  * - `runtimes` — the package.json `matbotRuntime`, when the host has already read it; used
66
69
  * for the pre-import gate in place of `resolver.runtimes(spec)`.
70
+ * - `notes` — what the host noticed while resolving that would explain a failure but does not
71
+ * itself cause one: a dependency it could not satisfy, an import shape it cannot fetch, a fetch
72
+ * that failed before the import was even attempted. Appended to any failure recorded for this
73
+ * spec, because the failure that surfaces is downstream of the cause (a missing dependency
74
+ * arrives as ERR_MODULE_NOT_FOUND naming a file) — and dropped entirely if the plugin loads,
75
+ * since a host cannot tell an unused declaration from a fatal one.
67
76
  *
68
77
  * @param bustCache When true, the URL imported for each specifier gets a unique
69
78
  * `${FRESH_PARAM}` query stamp appended before importing — either a host-pre-resolved
@@ -91,13 +100,22 @@ let freshSeq = 0;
91
100
  * only as a confusing empty-result error downstream — fail loudly with the reason instead.
92
101
  */
93
102
  export async function loadPlugins(
94
- specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; runtimes?: readonly Runtime[] })[],
103
+ specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] })[],
95
104
  services: MatbotMachine,
96
105
  bustCache = false,
97
106
  prompt?: PromptFn,
98
107
  onLoadError: 'skip' | 'throw' = 'skip',
99
108
  ): Promise<MatbotPlugin[]> {
100
- const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; runtimes?: readonly Runtime[] });
109
+ const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] });
110
+
111
+ // A reason reaching a user, decorated with what the host noticed while resolving this spec. The
112
+ // failure a load reports is usually downstream of its cause, and the host is the only layer that
113
+ // saw the cause.
114
+ const notesBySpec = new Map(reqs.filter(r => r.notes !== undefined && r.notes.length > 0).map(r => [r.spec, r.notes!]));
115
+ const withNotes = (spec: string, reason: string): string => {
116
+ const notes = notesBySpec.get(spec);
117
+ return notes === undefined ? reason : `${reason}\nNoted while resolving it:\n${notes.map(n => ` - ${n}`).join('\n')}`;
118
+ };
101
119
  if (bustCache) {
102
120
  console.debug(
103
121
  `[matbot] cache-bust requested for ${specifiers.length} plugin(s); the entry is re-evaluated, ` +
@@ -118,9 +136,9 @@ export async function loadPlugins(
118
136
  ),
119
137
  );
120
138
 
121
- const toLoad: { spec: string; importSpec: string; resolvedUrl: string; name?: string; runtimes?: readonly Runtime[] }[] = [];
139
+ const toLoad: { spec: string; importSpec: string; resolvedUrl: string; name?: string; version?: string; runtimes?: readonly Runtime[] }[] = [];
122
140
  for (let i = 0; i < reqs.length; i++) {
123
- const { spec, importSpec, name } = reqs[i]!;
141
+ const { spec, importSpec, name, version } = reqs[i]!;
124
142
  const runtimes = declared[i];
125
143
  if (runtimes !== undefined && runtimes.length > 0 && !runtimes.includes(CURRENT_RUNTIME)) {
126
144
  if (onLoadError === 'throw') {
@@ -139,6 +157,7 @@ export async function loadPlugins(
139
157
  // live plugin set). Falls back to the bare spec when the host pre-resolved nothing.
140
158
  resolvedUrl: importSpec ?? spec,
141
159
  ...(name !== undefined ? { name } : {}),
160
+ ...(version !== undefined ? { version } : {}),
142
161
  ...(runtimes !== undefined ? { runtimes } : {}),
143
162
  });
144
163
  }
@@ -157,7 +176,8 @@ export async function loadPlugins(
157
176
  // from a `'load'` failure (import rejection — a bad path, a syntax error). Only the former is
158
177
  // permanently-not-a-plugin: it throws NotAPluginError so the `add` flow can roll back the config
159
178
  // write, where an import failure may be a fixable typo and is left in config.
160
- const failLoad = (spec: string, reason: string, kind: 'load' | 'shape' = 'load'): void => {
179
+ const failLoad = (spec: string, rawReason: string, kind: 'load' | 'shape' = 'load'): void => {
180
+ const reason = withNotes(spec, rawReason);
161
181
  if (onLoadError === 'throw') throw kind === 'shape' ? notAPluginError(spec, reason) : new Error(reason);
162
182
  console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
163
183
  recordFailedPlugin({ specifier: spec, error: reason });
@@ -216,7 +236,11 @@ export async function loadPlugins(
216
236
  const name = toLoad[i]!.name ?? (services.resolver !== undefined ? await services.resolver.identify(spec) : defaultIdentify(spec));
217
237
  const source = sourceOf(spec);
218
238
  const runtimes = toLoad[i]!.runtimes;
219
- const version = services.resolver?.version !== undefined ? await services.resolver.version(spec) : undefined;
239
+ // Like `name`: the host's own reading wins, because it read the package.json it actually
240
+ // imported. `resolver.version(spec)` cannot — `spec` may be a bare name, a URL or a version
241
+ // range, none of which locate a manifest, so a plugin configured by name reported no version.
242
+ const version = toLoad[i]!.version
243
+ ?? (services.resolver?.version !== undefined ? await services.resolver.version(spec) : undefined);
220
244
  const plugin: MatbotPlugin = {
221
245
  ...spec_obj,
222
246
  name,
@@ -248,7 +272,7 @@ export async function loadPlugins(
248
272
  // Graceful but not silent: record the setup failure (skip mode — the boot batch) so the `plugin`
249
273
  // tool and web UI can show it. Explicit single loads (`onLoadError: 'throw'`) surface the error to
250
274
  // the caller directly, so they are not added to the persistent failed list.
251
- if (onLoadError === 'skip') recordFailedPlugin({ specifier: spec, name: plugin.name, error: reason });
275
+ if (onLoadError === 'skip') recordFailedPlugin({ specifier: spec, name: plugin.name, error: withNotes(spec, reason) });
252
276
  // Roll back partial registration so a failed load leaves no ghost state. setupPlugin
253
277
  // can throw after the plugin is already in the registry with some of its tools / hooks /
254
278
  // services / provider / storage wired (a wrong-platform plugin touching a missing
@@ -9,7 +9,7 @@ import type { HookRegistry } from './hooks.js';
9
9
  import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
10
10
  import { appendMessage, createMessage } from './session.js';
11
11
  import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
12
- import { machineBusy, quiesced, withUsageScope } from '@matatbread/matbot-plugin-api/host';
12
+ import { machineBusy, withUsageScope } from '@matatbread/matbot-plugin-api/host';
13
13
  import { runSession } from './runner.js';
14
14
 
15
15
  export interface SessionRunnerDeps {
@@ -264,12 +264,13 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
264
264
  // usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
265
265
  // so no early return out of the loop below can leave the machine held.
266
266
  //
267
- // Wait out any deferred work still settling BEFORE holding: a flusher that rewrites this very
268
- // session (a `session_edit` deferred out of the last turn) must finish before this turn reads its
269
- // copy, or the read would take the pre-edit document and the write-back below would put it back.
270
- // The edge is only reachable while nothing holds the machine, so the wait comes first and the
271
- // hold second — the reverse would deadlock on an edge this pump is itself preventing.
272
- return quiesced().then(() => machineBusy(async () => {
267
+ // It also waits for deferred work to land before taking the hold, which is why there is no separate
268
+ // `quiesced()` here any more: a flusher that rewrites this very session (a `session_edit` deferred
269
+ // out of the last turn) finishes before the loop below reads its copy, or the read would take the
270
+ // pre-edit document and the write-back would put it back. That wait used to be spelled at this call
271
+ // site, and spelling it here made it something the next caller of `machineBusy` could omit without
272
+ // any symptom — so it moved inside the hold it guards.
273
+ return machineBusy(async () => {
273
274
  try {
274
275
  while (s.queue.length > 0) {
275
276
  // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
@@ -545,7 +546,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
545
546
  notify(s, { type: 'idle', sessionId: id });
546
547
  maybeCleanup(id, s);
547
548
  }
548
- }));
549
+ });
549
550
  };
550
551
 
551
552
  return {