@matatbread/matbot-plugin-api 0.4.15 → 0.4.17

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.15",
7
+ "version": "0.4.17",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -104,15 +104,13 @@ export type Quiescer = (unregister: () => void) => void | Promise<void>;
104
104
  * onContextQuiesce(() => { if (nothingToDo) return; … });
105
105
  * ```
106
106
  *
107
- * **This or {@link scheduleAtEdge}?** They answer different questions, and the difference is what happens
108
- * when you ask twice.
107
+ * **This or {@link scheduleAtEdge}?** The difference is what happens when you ask twice.
109
108
  *
110
109
  * This is a *subscription*: each call adds a callback, and every callback added runs. Ask twice and the work
111
110
  * happens twice — correct when each call carries its own work (two deferred session edits are two edits, and
112
- * both must land). `scheduleAtEdge` is a *dirty flag*: you create one scheduler up front, and however many
113
- * times you poke it before an edge, the work runs once. Correct when the calls describe a **slot** rather
114
- * than a queue — three `register('StorageBackend')` calls mean one backend to install, not three to install
115
- * in turn, and the work reads the slot at fire time so the last writer wins.
111
+ * both must land). `scheduleAtEdge` is a *dirty flag*: one scheduler, poked any number of times before an
112
+ * edge, runs once and reads a slot at fire time. Correct when the calls describe a **slot** rather than a
113
+ * queue — three `register('StorageBackend')` calls mean one backend to install, not three in turn.
116
114
  *
117
115
  * Picking wrongly is not subtle in either direction: a subscription where you wanted a flag re-applies stale
118
116
  * intermediate values, and a flag where you wanted a subscription silently drops work.
@@ -125,36 +123,24 @@ export type Quiescer = (unregister: () => void) => void | Promise<void>;
125
123
  *
126
124
  * - Flushers are **invoked in registration order**, in one synchronous sweep; one that goes async is
127
125
  * started in that order and then left to settle alongside the rest.
128
- * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing
129
- * yields at all, which is what lets the host stage a mutation and land it with a bare
130
- * `flushIfQuiescent()` — in effect before the call returns.
126
+ * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing yields at
127
+ * all, so a host can stage a mutation and have it in effect before the staging call returns.
131
128
  * - **No second sweep begins until the current one has completely settled**, and a mutation staged
132
129
  * meanwhile is turned away rather than applied.
133
130
  * - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
134
131
  * completion, nor escape into the operation whose release happened to reach the edge.
132
+ * - **Exclusivity, for a flusher's whole extent**, because {@link machineBusy} bars entry while a flush is
133
+ * settling. Flushers still overlap *each other*, deliberately: sequencing them would remove one source of
134
+ * concurrent mutation while leaving every other in place, at the price of every flusher waiting on the
135
+ * slowest. Keeping a flusher synchronous is still worth it where the work allows.
135
136
  *
136
- * - **Exclusivity, for a flusher's whole extent.** A synchronous flusher has always had it for free —
137
- * the runtime is single-threaded — and that remains the argument for keeping one synchronous whenever
138
- * the work allows. An asynchronous one now has it too, because {@link machineBusy} bars entry while a
139
- * flush is settling. Flushers still overlap *each other*, deliberately: sequencing them would remove
140
- * one source of concurrent mutation while leaving every other in place, at the price of every flusher
141
- * waiting on the slowest.
142
- *
143
- * Exclusivity is a recent acquisition and the reasoning that preceded it is worth keeping, because it
144
- * says what the barrier is really for. This edge used to be a counter, not a gate: `depth` was 0 while
145
- * a flush settled, so an HTTP endpoint could accept a request and run a whole tool call inside a single
146
- * `await`, and the conclusion drawn was that contention over a service is the *service's* to resolve —
147
- * a `Store` answers it with compare-and-swap — and that the sweep's job was only to make contention
148
- * rare rather than to pretend the machine stops.
149
- *
150
- * That is still true, and `mediumGuard` is the shipped form of it: a write whose read came from another
151
- * `StorageBackend` fails on the version stamp, so coherence does not depend on this module at all.
152
- * Which is precisely what makes the barrier safe to add. It is not the correctness mechanism; it is the
153
- * **liveness** one. A counter cannot force `depth` to reach 0, so under continuously overlapping holds —
154
- * several sessions on a busy server, each pump holding across its own queue — the edge simply never
155
- * arrives and staged work waits for an idle moment that never comes. A deferred session edit that never
156
- * lands, and a staged backend swap that never applies, are both failures with no symptom. Barring entry
157
- * is the only way to make the drain reachable, and exclusivity falls out of it for free.
137
+ * **The barrier is for liveness, not coherence.** Coherence does not depend on this module at all:
138
+ * `mediumGuard` fails a write whose read came from another `StorageBackend` on the version stamp, and
139
+ * contention over a service is that service's to resolve (a `Store` answers it with compare-and-swap). What
140
+ * a counter *cannot* do is force `depth` to reach 0 — under continuously overlapping holds (several sessions
141
+ * on a busy server, each pump holding across its own queue) the edge never arrives, and a deferred session
142
+ * edit that never lands and a staged swap that never applies are both failures with no symptom. Barring
143
+ * entry is the only way to make the drain reachable.
158
144
  *
159
145
  * The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
160
146
  * registers — carry no protection here or anywhere: they change under a running turn too, by the
@@ -165,16 +151,13 @@ export function onContextQuiesce(flush: Quiescer): () => void {
165
151
  const unregister = (): void => { cs.quiescers.delete(flush); };
166
152
  cs.quiescers.add(flush);
167
153
 
168
- // **Registering IS announcing.** A stager should not have to also remember to ask for an edge: the one
169
- // plugin doing this in-tree registered a one-shot and never called `flushIfQuiescent`, so the barrier
170
- // never engaged on its behalf and its work depended on someone else happening to release. Two calls that
171
- // must be paired, where omitting the second is silent, is the shape this module exists to avoid.
154
+ // **Registering IS announcing**, because two calls that must be paired, where omitting the second is
155
+ // silent, is the shape this module exists to avoid: a stager that registered and forgot to ask for an
156
+ // edge left the barrier disengaged, and its work then depended on someone else happening to release.
172
157
  //
173
158
  // The barrier is raised synchronously (it invokes nothing) so it engages at once, while the edge ATTEMPT
174
159
  // waits for a microtask — a callback must not run before the statement registering it has finished, or a
175
160
  // one-shot reading anything initialised on that same line, `unregister` included, would see it missing.
176
- // Nothing depends on registration landing synchronously; the host's inline-landing guarantee is attached
177
- // to an explicit `flushIfQuiescent()`, which still has it.
178
161
  if (cs.depth !== 0 || cs.flushing) cs.wanted = true;
179
162
  void Promise.resolve().then(() => { if (cs.quiescers.has(flush)) flushIfQuiescent(); });
180
163
 
@@ -183,13 +166,8 @@ export function onContextQuiesce(flush: Quiescer): () => void {
183
166
 
184
167
  /**
185
168
  * Announce registered work and land it if the edge is already here — **private**, and the second half of
186
- * {@link onContextQuiesce}.
187
- *
188
- * It was public, and announcing was then a call a stager had to remember to pair with its registration. The
189
- * one plugin registering a one-shot never made it, so the barrier never engaged for the very work whose
190
- * starvation motivated it. Registering announces now, which is the same guarantee with nothing to forget —
191
- * and once that was true, nothing outside this module had any reason to call this. Exposing "force an edge"
192
- * only offered a way to reason about firing that a caller should not have to have.
169
+ * {@link onContextQuiesce}. Private because registering announces, so nothing outside this module has any
170
+ * reason to force an edge; offering one only invites reasoning about firing that a caller should not need.
193
171
  *
194
172
  * It stays separate from the opportunistic sweep {@link machineBusy} performs at its own edges: only a
195
173
  * registration knows work exists, and a sweep that raised `wanted` merely because it found the machine busy
@@ -277,10 +255,9 @@ function sweep(): Promise<void> | undefined {
277
255
  * caller through here — which is all the mount contract has ever promised: eventual and ordered,
278
256
  * never timed.
279
257
  *
280
- * **Largely redundant since the barrier.** {@link machineBusy} now waits for staged work before it takes
281
- * the hold, which is this guarantee delivered where it cannot be forgotten — so the pump, the caller this
282
- * was written for, no longer calls it. It remains for an operation that needs deferred work complete and
283
- * does *not* want to hold the machine.
258
+ * **Largely redundant since the barrier**, which delivers this guarantee inside {@link machineBusy} where
259
+ * it cannot be forgotten. It remains for an operation that needs deferred work complete and does *not*
260
+ * want to hold the machine.
284
261
  */
285
262
  export async function quiesced(): Promise<void> {
286
263
  await sweep();
@@ -369,23 +346,21 @@ async function admit(): Promise<void> {
369
346
  * off-limits to further ones until `fn` is done. When `fn` is async the hold stays up until its promise
370
347
  * settles, so the count tracks the real operation rather than the synchronous call.
371
348
  *
372
- * **Always a promise, because entry can wait** ({@link admit}) — that is the barrier, and it is in here
373
- * rather than at the call site on purpose. An explicit "wait, then hold" pair reads better and puts the
374
- * cost in view, but it makes the waiting *optional*, and a second caller who omits it gets no error and
375
- * no symptom: staged work simply stops landing. That is the failure this whole module exists to prevent,
376
- * so it is not one to leave to a caller's memory — the same reasoning that makes the principal ambient
377
- * rather than threaded, and binds a mount interest to its plugin's load extent.
349
+ * **Always a promise, because entry can wait** ({@link admit}) — the barrier is in here rather than at the
350
+ * call site on purpose. An explicit "wait, then hold" pair reads better and puts the cost in view, but it
351
+ * makes the waiting *optional*, and a caller who omits it gets no error and no symptom: staged work simply
352
+ * stops landing. Same reasoning that makes the principal ambient rather than threaded, and binds a mount
353
+ * interest to its plugin's load extent.
378
354
  *
379
355
  * When nothing is staged, `fn` still runs **synchronously** within the call, before the returned promise
380
356
  * is handed back: the barrier adds a hold, never a scheduling gap, so nothing can slip between the check
381
357
  * and the hold. A synchronous throw becomes a rejection, so the two paths report failure identically.
382
358
  *
383
359
  * The hold is released on **every** exit — a throw, a rejected promise, a bare `return` from anywhere
384
- * inside — because the release is bound to the call, not written at the exits. That is the whole reason
385
- * this is a wrapper and not a `begin()`/`end()` pair: a caller with a dozen early returns (the pump has
386
- * several per queue item) cannot leave the counter stuck, and a stuck counter is unrecoverable — every
387
- * later flush would silently no-op forever, and the symptom would be a deferred mutation that simply
388
- * never happens.
360
+ * inside — because the release is bound to the call, not written at the exits. That is why this is a
361
+ * wrapper and not a `begin()`/`end()` pair: a caller with a dozen early returns (the pump has several per
362
+ * queue item) cannot leave the counter stuck, and a stuck counter is unrecoverable — every later flush
363
+ * would silently no-op forever, with no symptom but a deferred mutation that never happens.
389
364
  */
390
365
  export function machineBusy<T>(fn: () => T | Promise<T>): Promise<T> {
391
366
  if (!clear()) return admit().then(() => held(fn));
package/src/hooks.ts CHANGED
@@ -2,7 +2,7 @@ import type {
2
2
  Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen,
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
4
  } from './types.js';
5
- import { foldOntoUserTurn } from './session.js';
5
+ import { foldOntoUserTurn, createMessage } from './session.js';
6
6
  import { withUsageSite } from './usage-context.js';
7
7
 
8
8
  const HOOK_ERROR_CREATOR = 'matbot-hooks';
@@ -20,11 +20,20 @@ export class HookRegistry implements HookRegistrar {
20
20
  private readonly markedFailures = new Set<Hook>();
21
21
  private pendingFailureMarkers: { channel: HookPoint; pluginName?: string; message: string }[] = [];
22
22
 
23
+ // A fresh array, never an in-place push + sort, for the reason given at `removeOne`: a run loop
24
+ // iterating the old list across its awaits would otherwise visit a hook registered mid-run, or visit
25
+ // one twice after the sort moved it behind the cursor.
23
26
  register(hook: Hook): void {
24
- const list = this.hooks.get(hook.on) ?? [];
25
- list.push(hook);
26
- list.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
27
- this.hooks.set(hook.on, list);
27
+ const list = [...(this.hooks.get(hook.on) ?? []), hook];
28
+ this.hooks.set(hook.on, list.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50)));
29
+ }
30
+
31
+ // The hooks registered on one channel, narrowed to that channel's arm. The map is keyed BY `on`, so
32
+ // its members are already the right arm; each run* method used to re-test `if (hook.on !== 'screen')
33
+ // continue` purely to tell TypeScript so — five guards that could never fire. Narrowing at the lookup
34
+ // says it once, where it is true by construction.
35
+ private on<P extends HookPoint>(point: P): Extract<Hook, { on: P }>[] {
36
+ return (this.hooks.get(point) ?? []) as Extract<Hook, { on: P }>[];
28
37
  }
29
38
 
30
39
  removeByPlugin(pluginName: string): void {
@@ -79,12 +88,10 @@ export class HookRegistry implements HookRegistrar {
79
88
  // fallback path, not the only one).
80
89
  private drainFailureMarkers(session: Session): { session: Session; markers: MessageContent[] } {
81
90
  if (this.pendingFailureMarkers.length === 0) return { session, markers: [] };
82
- const messages: Message[] = this.pendingFailureMarkers.map(f => ({
83
- id: crypto.randomUUID(),
84
- role: 'marker',
85
- createdAt: new Date().toISOString(),
86
- traceId: '',
87
- content: [{
91
+ const messages: Message[] = this.pendingFailureMarkers.map(f => createMessage({
92
+ role: 'marker',
93
+ traceId: '',
94
+ content: [{
88
95
  type: 'marker',
89
96
  creator: HOOK_ERROR_CREATOR,
90
97
  data: { channel: f.channel, ...(f.pluginName !== undefined ? { pluginName: f.pluginName } : {}), message: f.message },
@@ -110,9 +117,8 @@ export class HookRegistry implements HookRegistrar {
110
117
  const handlerMarkers: MessageContent[] = [];
111
118
  const appendMarkers = (blocks: MessageContent[]): void => {
112
119
  handlerMarkers.push(...blocks);
113
- session = { ...session, messages: [...session.messages, {
114
- id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: '', content: blocks,
115
- }] };
120
+ session = { ...session, messages: [...session.messages,
121
+ createMessage({ role: 'marker', traceId: '', content: blocks })] };
116
122
  };
117
123
  // Handler-returned durable context: folded onto the running turn's user message (the last one
118
124
  // with role 'user'), so it persists into history AND rides every subsequent provider call — the
@@ -127,8 +133,7 @@ export class HookRegistry implements HookRegistrar {
127
133
  durable.push(...blocks);
128
134
  session = folded;
129
135
  };
130
- for (const hook of this.hooks.get('screen') ?? []) {
131
- if (hook.on !== 'screen') continue;
136
+ for (const hook of this.on('screen')) {
132
137
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
133
138
  if (!r) continue;
134
139
  if (r.session) session = r.session;
@@ -149,8 +154,7 @@ export class HookRegistry implements HookRegistrar {
149
154
  // session is never touched.
150
155
  async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
151
156
  let outgoing = ctx.outgoing as Message[];
152
- for (const hook of this.hooks.get('contribute') ?? []) {
153
- if (hook.on !== 'contribute') continue;
157
+ for (const hook of this.on('contribute')) {
154
158
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
155
159
  if (r) outgoing = r;
156
160
  }
@@ -159,8 +163,7 @@ export class HookRegistry implements HookRegistrar {
159
163
 
160
164
  // toolcall stops at the first hook that rejects or aborts; the rest don't run.
161
165
  async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
162
- for (const hook of this.hooks.get('toolcall') ?? []) {
163
- if (hook.on !== 'toolcall') continue;
166
+ for (const hook of this.on('toolcall')) {
164
167
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
165
168
  if (r && (r.rejectTool || r.abort)) return r;
166
169
  }
@@ -171,8 +174,7 @@ export class HookRegistry implements HookRegistrar {
171
174
  // a hook that returns nothing just observes (auditing). Returns the final result.
172
175
  async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
173
176
  let result = ctx.result;
174
- for (const hook of this.hooks.get('toolresult') ?? []) {
175
- if (hook.on !== 'toolresult') continue;
177
+ for (const hook of this.on('toolresult')) {
176
178
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
177
179
  if (r) result = r.result;
178
180
  }
@@ -188,17 +190,20 @@ export class HookRegistry implements HookRegistrar {
188
190
  const markers: MessageContent[] = [];
189
191
  const retractCtx: MessageContent[] = [];
190
192
  const retractDur: MessageContent[] = [];
191
- for (const hook of this.hooks.get('followup') ?? []) {
192
- if (hook.on !== 'followup') continue;
193
+ // Whether a hook ASKED to retract, not how much it sent with the request. Deriving it from the
194
+ // payload size dropped a `retractAndRerun: {}` — type-legal, both fields being optional — so a hook
195
+ // asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
196
+ let retracting = false;
197
+ for (const hook of this.on('followup')) {
193
198
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
194
199
  if (r?.resubmit) resubmits.push(r.resubmit.content);
195
200
  if (r?.retractAndRerun) {
201
+ retracting = true;
196
202
  if (r.retractAndRerun.context) retractCtx.push(...r.retractAndRerun.context);
197
203
  if (r.retractAndRerun.durable) retractDur.push(...r.retractAndRerun.durable);
198
204
  }
199
205
  if (r?.markers) markers.push(...r.markers);
200
206
  }
201
- const retracting = retractCtx.length > 0 || retractDur.length > 0;
202
207
  return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
203
208
  }
204
209
  }
@@ -2,6 +2,7 @@ import type {
2
2
  CompletionRequest, MatbotMachine, MatbotServices, Mounted, MountConsumeOptions, SingleTurnRequest,
3
3
  } from './plugin.js';
4
4
  import { RegistryChangeKind } from './notify.js';
5
+ import { createMessage } from './session.js';
5
6
 
6
7
  /*
7
8
  * Host-side machine assembly — the wiring an *embedder* does once at boot, not API a plugin author
@@ -50,8 +51,15 @@ export type SwapFn<T extends object> = (next: T) => void;
50
51
  * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
51
52
  * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
52
53
  * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
53
- * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
54
- * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
54
+ * ownKeys + getOwnPropertyDescriptor keep object spread faithful. A nullish current (an optional service
55
+ * with nothing registered yet) reads as empty.
56
+ *
57
+ * **Method identity is not stable**, and callers do need to know: a `get` binds to the impl current at
58
+ * that moment, so each read produces a NEW function and `store.get !== store.get`. That is what makes a
59
+ * captured method follow a swap instead of calling into the displaced impl — the point of the whole
60
+ * proxy — but it means anything keyed on a method's identity silently never matches: a `Set` of
61
+ * callbacks it is asked to remove one from, a memo keyed by function, an `===` comparison against a
62
+ * previously-read method. Capture the SERVICE and call through it; do not capture its methods.
55
63
  */
56
64
  export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
57
65
  return new Proxy({} as T, {
@@ -198,16 +206,17 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
198
206
 
199
207
  /**
200
208
  * Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
201
- * otherwise-mandatory and meaningless Message fields (id/traceId/createdAt) an out-of-band one-shot
202
- * has no use for. Pure; the host invokes its own complete() with the result.
209
+ * otherwise-mandatory Message fields an out-of-band one-shot has no use for. The host invokes its own
210
+ * complete() with the result.
203
211
  */
204
212
  export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
205
213
  return {
206
214
  provider: req.provider,
207
- messages: [{
208
- id: '', traceId: '', createdAt: new Date().toISOString(), role: 'user',
215
+ messages: [createMessage({
216
+ role: 'user',
217
+ traceId: '',
209
218
  content: typeof req.prompt === 'string' ? [{ type: 'text', text: req.prompt }] : req.prompt,
210
- }],
219
+ })],
211
220
  ...(req.system !== undefined ? { system: req.system } : {}),
212
221
  ...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
213
222
  ...(req.signal !== undefined ? { signal: req.signal } : {}),
package/src/host.ts CHANGED
@@ -33,6 +33,10 @@ export * from './usage-context.js';
33
33
  // The machine half of a context switch: quiescent-edge flushers and the deferred-swap application point.
34
34
  export * from './context-switch.js';
35
35
 
36
+ // Re-entering an ambient scope on every pull of an async iterator — what both carriers need to cover a
37
+ // tool executor's deferred body. Host-side: a plugin gets this behaviour for free from `runAs`.
38
+ export * from './scoped-iterator.js';
39
+
36
40
  // The boot `PermissionGate` a host installs as the base of the swap-member: ask through whatever
37
41
  // channel is in scope, else answer the request's own `fallback`. A plugin supplying a *policy* is the
38
42
  // author-facing half and needs only the `PermissionGate` type, which the root exports.
@@ -108,6 +108,21 @@ export function invokeTool<K extends string, const P>(
108
108
  return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultFor<K, P>>>;
109
109
  }
110
110
 
111
+ /**
112
+ * The one drain: consume a tool event stream, keeping the last `result` and throwing on the first
113
+ * `error`. Whether a stream that yielded NO result is a failure is the caller's question, and the only
114
+ * thing the three public drains ever disagreed on — so it is the only thing they pass in.
115
+ */
116
+ async function drain<R>(events: AsyncIterable<ToolEvent<R>>): Promise<{ value: R | undefined; hadResult: boolean }> {
117
+ let value: R | undefined;
118
+ let hadResult = false;
119
+ for await (const ev of events) {
120
+ if (ev.type === 'result') { value = ev.value; hadResult = true; }
121
+ else if (ev.type === 'error') { throw new Error(ev.message); }
122
+ }
123
+ return { value, hadResult };
124
+ }
125
+
111
126
  /**
112
127
  * Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
113
128
  * paired with `invokeTool(machine, name, …)` it returns whatever `ToolContracts[name]` declares (or
@@ -116,14 +131,9 @@ export function invokeTool<K extends string, const P>(
116
131
  * Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
117
132
  */
118
133
  export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
119
- let value!: R;
120
- let hadResult = false;
121
- for await (const ev of events) {
122
- if (ev.type === 'result') { value = ev.value; hadResult = true; }
123
- else if (ev.type === 'error') { throw new Error(ev.message); }
124
- }
134
+ const { value, hadResult } = await drain(events);
125
135
  if (!hadResult) throw new Error('Tool produced no result');
126
- return value;
136
+ return value as R;
127
137
  }
128
138
 
129
139
  /**
@@ -133,13 +143,7 @@ export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promis
133
143
  * (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
134
144
  */
135
145
  export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
136
- let result: unknown;
137
- let hadResult = false;
138
- for await (const ev of events) {
139
- if (ev.type === 'result') { result = ev.value; hadResult = true; }
140
- else if (ev.type === 'error') { throw new Error(ev.message); }
141
- }
142
- if (!hadResult) throw new Error('Tool produced no result');
146
+ const result = await toolResult(events);
143
147
 
144
148
  if (typeof result === 'string') return result;
145
149
  if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
@@ -148,9 +152,28 @@ export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string
148
152
  return JSON.stringify(result, null, 2);
149
153
  }
150
154
 
151
- const rejectingPrompt: PromptFn = (((p: string | FormField): Promise<string> => {
152
- const label = typeof p === 'string' ? p : p.label;
153
- return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
155
+ const cannotPrompt = (p: string | FormField): Promise<string> =>
156
+ Promise.reject(new Error(`Non-interactive context: cannot prompt for "${typeof p === 'string' ? p : p.label}"`));
157
+
158
+ /**
159
+ * The stand-in for "there is nobody to ask": every request rejects, naming the field, which a tool's
160
+ * surrounding try/catch turns into an ordinary error event.
161
+ *
162
+ * Deliberately NOT what a gate is handed — see {@link bindGate}. A gate must be able to tell "nobody is
163
+ * here" (`ask === undefined`) from "a human answered", and a stand-in that answers makes that
164
+ * undecidable.
165
+ */
166
+ export const rejectingPrompt: PromptFn = cannotPrompt as PromptFn;
167
+
168
+ /**
169
+ * The stand-in that answers with a field's OWN default where it has one, and rejects like
170
+ * {@link rejectingPrompt} where it does not — what `ToolContext.prompt` is when the host supplied no
171
+ * channel. The two stand-ins shared one message written out twice, in two packages; what actually
172
+ * separates them is this one line, so it is the only thing that differs now.
173
+ */
174
+ export const defaultingPrompt: PromptFn = (((p: string | FormField, def?: string): Promise<string> => {
175
+ const fallback = typeof p === 'string' ? def : p.default;
176
+ return fallback !== undefined ? Promise.resolve(fallback) : cannotPrompt(p);
154
177
  }) as PromptFn);
155
178
 
156
179
  /** Compact a value to one trace line. */
@@ -161,6 +184,25 @@ function compactTrace(v: unknown): string {
161
184
  return s.length > 240 ? `${s.slice(0, 240)}…` : s;
162
185
  }
163
186
 
187
+ /**
188
+ * The {@link ToolProxy}'s drain. Unlike {@link toolResult}, a stream that ends with no `result` resolves
189
+ * to `undefined` instead of throwing — a tool whose work is a side effect yields nothing BY DESIGN
190
+ * (`function-tools` omits the event when a body returns nothing, and the triggers dispatcher fires only
191
+ * on a yielded result), and this proxy is the surface such a tool is called through. Throwing here made
192
+ * the one surface meant to be silent the one that shouted.
193
+ *
194
+ * A tool that declares a DATA result and yields nothing is still an error — but it is caught where the
195
+ * types are, not re-derived from a contract string on every call: the checker compiles a body against its
196
+ * declared return type, and `strict` rejects one that can fall through (TS2355 with no return at all,
197
+ * TS2366 when only some paths do). A body declaring `T | undefined` is deliberately NOT rejected —
198
+ * `undefined` IS its contract, and is exactly what this drain hands back. Two paths reach here unchecked
199
+ * and resolve to `undefined` rather than throwing: an explicit `noTypeCheck`, and the browser, where there
200
+ * is no `ToolTypeIndex`. Both are opted into.
201
+ */
202
+ async function drainProxyResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R | undefined> {
203
+ return (await drain(events)).value;
204
+ }
205
+
164
206
  /**
165
207
  * A factory for a context-overridden {@link ToolProxy}: `toolInContext({ provider }).some_tool(params)`
166
208
  * runs `some_tool` with the given fields merged over the box's bound context (omitted fields inherited).
@@ -195,7 +237,7 @@ export function makeToolBox(
195
237
  if (typeof prop !== 'string' || prop === 'then') return undefined;
196
238
  return async (params?: unknown): Promise<unknown> => {
197
239
  opts?.onEvent?.({ type: 'stdout', chunk: `→ ${prop}(${compactTrace(params)})\n` });
198
- const value = await toolResult(invokeTool(machine, prop, params ?? {}, call));
240
+ const value = await drainProxyResult(invokeTool(machine, prop, params ?? {}, call));
199
241
  opts?.onEvent?.({ type: 'stdout', chunk: `← ${prop}: ${compactTrace(value)}\n` });
200
242
  return value;
201
243
  };
package/src/notify.ts CHANGED
@@ -193,6 +193,7 @@ export interface Notifier {
193
193
  */
194
194
  export function createNotifier(defaultPlugin: string): Notifier {
195
195
  const events = createBroadcaster<Notification>();
196
+ const warned = new Set<string>();
196
197
  return {
197
198
  notify(notification) {
198
199
  const plugin = notification.plugin ?? defaultPlugin;
@@ -200,9 +201,12 @@ export function createNotifier(defaultPlugin: string): Notifier {
200
201
  // matter most here: a plain-JS plugin, or a bridge injecting another instance's traffic. Warn, don't
201
202
  // throw — `notify` is contractually non-throwing, and a badly-named kind is a hygiene fault, not a
202
203
  // delivery failure. Only the shape is checkable; that the prefix names the *definer* is not, since
203
- // the emitter legitimately differs from it.
204
- if (!notification.kind.includes('#')) {
205
- console.warn(`[matbot] ${plugin} published unqualified notification kind "${notification.kind}" — expected <package-name>#<InterfaceName>`);
204
+ // the emitter legitimately differs from it. Once per emitter and kind: the fault is in the source,
205
+ // so repeating it on every publish only buries the log.
206
+ const warning = `${plugin} published unqualified notification kind "${notification.kind}"`;
207
+ if (!notification.kind.includes('#') && !warned.has(warning)) {
208
+ warned.add(warning);
209
+ console.warn(`[matbot] ${warning} — expected <package-name>#<InterfaceName>`);
206
210
  }
207
211
  events.emit({ ...notification, plugin } as Notification);
208
212
  },
package/src/plugin.ts CHANGED
@@ -7,18 +7,16 @@ import type {
7
7
  import type { Notifications, Notifier } from './notify.js';
8
8
 
9
9
  /**
10
- * The plugin API contract version: this package's `major.minor`, and nothing else. It sat at `'0.1'`
11
- * while the package shipped 0.3.x, so it conveyed no information and could not be reasoned about at a
12
- * version boundary — the point at which it is the only thing every third-party plugin hardcodes.
10
+ * The plugin API contract version: this package's `major.minor`, and nothing else — it must track the
11
+ * package, being the only thing every third-party plugin hardcodes.
13
12
  *
14
13
  * The gate (`checkApiVersion`, in core's registry) reads it as: **major must match exactly** — a
15
14
  * mismatch is a hard load failure — and a plugin declaring a *newer minor* than the runtime warns and
16
15
  * loads, since it may reach for something absent. Declare it as `apiVersion: PLUGIN_API_VERSION` and it
17
16
  * stays right for free; that is what every plugin in this repo does.
18
17
  *
19
- * Bumping 0.1 → 0.4 breaks nothing: at 0.x the major is `'0'` either way, and an older declared minor
20
- * does not warn. It matters at 1.0.0, where a plugin still declaring `'0.x'` will fail the major check
21
- * loudly instead of loading against a contract it was never built for.
18
+ * It matters at 1.0.0, where a plugin still declaring `'0.x'` fails the major check loudly instead of
19
+ * loading against a contract it was never built for.
22
20
  */
23
21
  export const PLUGIN_API_VERSION = '0.4';
24
22
 
@@ -247,26 +245,8 @@ export interface MatbotRuntime {
247
245
  */
248
246
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
249
247
 
250
- /**
251
- * Mount-table notifications: react to a registry service (re)mounting or being unloaded. A plugin
252
- * needs this iff its setup() reads another service's *current state* to build cached/derived state —
253
- * skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A pure
254
- * map (no setup data; data arrives later as a tool call or hook) needs nothing — resolve the service
255
- * per-invocation through its proxy/member instead.
256
- *
257
- * // cache the backend's documents; rebuild on every swap (own initial load was in setup())
258
- * services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
259
- *
260
- * // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
261
- * services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
262
- *
263
- * Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
264
- * says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
265
- * never assume a register() is observed inline or at a turn boundary. A reload (unregister+register
266
- * before the edge) collapses to a single remount; an unregister not replaced by the edge is a
267
- * committed unload, delivered to `onUnmount` (drop your captured ref there to let the gone plugin's
268
- * working set be collected). Handlers may re-fire on later remounts, so they must be idempotent.
269
- */
248
+ /** Mount-table notifications: react to a registry service (re)mounting or being unloaded. See
249
+ * {@link Mounted} for when a plugin needs this, the two idioms, and what the contract guarantees. */
270
250
  readonly mounted: Mounted;
271
251
 
272
252
  /**
@@ -358,9 +338,29 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
358
338
  readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
359
339
  }
360
340
 
361
- /** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. `observe` (not `consume`)
362
- * so the verb `consume` means exactly one thing repo-wide — the detached stream drain on
363
- * {@link Subscribable}; this keyed, replay/onUnmount, edge-batched delivery is its own paradigm. */
341
+ /**
342
+ * The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}.
343
+ *
344
+ * A plugin needs this iff its `setup()` reads another service's *current state* to build cached/derived
345
+ * state — skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A
346
+ * pure map (no setup data; data arrives later as a tool call or hook) needs nothing, and resolves the
347
+ * service per-invocation through its proxy/member instead.
348
+ *
349
+ * // cache the backend's documents; rebuild on every swap (own initial load was in setup())
350
+ * services.mounted.observe({ key: 'StorageBackend' }, () => this.load());
351
+ *
352
+ * // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
353
+ * services.mounted.observe({ key: 'SkillManager', replay: true }, m => seed(m));
354
+ *
355
+ * The contract guarantees only *eventual, ordered* delivery of each key's net presence transition, and
356
+ * says nothing about timing — never assume a `register()` is observed inline or at a turn boundary. A
357
+ * reload (unregister+register before the edge) collapses to a single remount; an unregister not replaced
358
+ * by the edge is a committed unload, delivered to `onUnmount`. Handlers re-fire on a later remount, so
359
+ * they must be idempotent.
360
+ *
361
+ * `observe`, not `consume`, so the verb `consume` means exactly one thing repo-wide — the detached
362
+ * stream drain on {@link Subscribable}; this keyed, edge-batched delivery is its own paradigm.
363
+ */
364
364
  export interface Mounted {
365
365
  observe<K extends keyof MatbotServices>(
366
366
  options: MountConsumeOptions<K>,
@@ -374,11 +374,6 @@ export interface Mounted {
374
374
 
375
375
  export type ProviderAdapterFactory = (config: ProviderConfig) => ProviderAdapter;
376
376
 
377
- export type StoreFactory = (
378
- kind: string,
379
- options: Record<string, unknown>,
380
- ) => Store<{ id: string; version: string }>;
381
-
382
377
  // ── Plugin manifest ───────────────────────────────────────────────────────────
383
378
 
384
379
  export interface PluginManifest {
@@ -453,12 +448,11 @@ export interface VisibilityQuery {
453
448
  * notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
454
449
  * sees everything.
455
450
  *
456
- * **The predicate is consulted for EVERY kind, and decides for itself which it filters.** It used to be
457
- * reached only for an `ItemChange` carrying a principal, because the caller gated on that — which left
458
- * every plugin-defined kind fanned out unfiltered with no hook capable of stopping it, and made `kind` a
459
- * constant not worth passing. Moving that judgement here is the point: routing a partitioned namespace and
460
- * addressing a notification to a recipient are two different policies, and only the implementation knows
461
- * which of its kinds want which. An implementation that has nothing to say about a kind returns `true`.
451
+ * **The predicate is consulted for EVERY kind, and decides for itself which it filters** — so a
452
+ * plugin-defined kind cannot be fanned out unfiltered with no hook capable of stopping it. Routing a
453
+ * partitioned namespace and addressing a notification to a recipient are two different policies, and only
454
+ * the implementation knows which of its kinds want which. One with nothing to say about a kind returns
455
+ * `true`.
462
456
  */
463
457
  export interface WatchVisibility {
464
458
  /**
@@ -488,9 +482,8 @@ export interface WatchVisibility {
488
482
  * **The token is a partition, never a principal.** The two are not interchangeable: one principal may
489
483
  * hold several profiles, and a profile may alias a namespace onto a third profile's partition, so the
490
484
  * partition serving a principal's files is not derivable from the principal id. Minting the token from
491
- * the identity in force — the bug this replaces — attached an addressable partition to files that were
492
- * in the shared base area, and would have pointed at an empty partition the moment one was created
493
- * under that name.
485
+ * the identity in force instead attaches an addressable partition to files that are in the shared base
486
+ * area, and points at an empty partition the moment one is created under that name.
494
487
  *
495
488
  * `current` and `enter` are one round trip and must stay each other's inverse: whatever `current`
496
489
  * returns, `enter` puts a reader back into the area those bytes were written to. Both live here rather
@@ -518,7 +511,6 @@ export interface MatbotPluginSpec {
518
511
  readonly apiVersion: string;
519
512
  readonly manifest?: PluginManifest;
520
513
  readonly provider?: ProviderAdapterFactory;
521
- readonly storage?: Record<string, StoreFactory>;
522
514
  readonly tools?: readonly Tool[];
523
515
  /**
524
516
  * When present, the runtime calls open(dotData) before creating the services object and uses the
@@ -1,4 +1,5 @@
1
1
  import type { Principal } from './types.js';
2
+ import { scopeIterator } from './scoped-iterator.js';
2
3
 
3
4
  /**
4
5
  * Ambient carrier for the security {@link Principal} — the single mechanism by which any layer
@@ -115,32 +116,13 @@ function isAsyncIterator(value: unknown): value is AsyncIterator<unknown> & Asyn
115
116
  && typeof (value as AsyncIterator<unknown>).next === 'function';
116
117
  }
117
118
 
119
+ // The per-pull re-entry itself is `scopeIterator` (./scoped-iterator.ts), shared with the usage carrier.
118
120
  function scopedIterator<T>(
119
121
  carrier: PrincipalCarrier,
120
122
  principal: Principal,
121
123
  source: AsyncIterator<T> & AsyncIterable<T>,
122
124
  ): AsyncIterableIterator<T> {
123
- const inScope = <R>(f: () => R): R => carrier.run(principal, f);
124
- // A Proxy rather than a fresh object literal: the value crossing back out must still BE what it was —
125
- // a custom iterator's own methods, its prototype, `instanceof` — so only the pull points are replaced.
126
- // Anything else is forwarded bound to the target, which a class-based iterator holding internal state
127
- // (or an object with internal slots) requires.
128
- const pulls: Record<PropertyKey, unknown> = {
129
- [Symbol.asyncIterator]: () => proxy,
130
- next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
131
- ...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
132
- ...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
133
- };
134
- const proxy = new Proxy(source, {
135
- // `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
136
- // off the lookup object instead of the iterator being wrapped.
137
- get(target, prop) {
138
- if (Object.hasOwn(pulls, prop)) return pulls[prop];
139
- const value = Reflect.get(target, prop, target);
140
- return typeof value === 'function' ? value.bind(target) : value;
141
- },
142
- }) as AsyncIterableIterator<T>;
143
- return proxy;
125
+ return scopeIterator(source, f => carrier.run(principal, f));
144
126
  }
145
127
 
146
128
  /** Imperatively establish `principal` for the current flow (entry points only). */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Re-enter an ambient scope on every pull of an async iterator.
3
+ *
4
+ * The problem is the same wherever it appears: an async generator's body does not begin until the first
5
+ * pull, so an iterator returned out of a scope carries its whole extent OUT of it — the scope covered
6
+ * the construction, and the work then runs in whatever flow happens to pull it. The tool ABI
7
+ * (`executor.execute()`) returns exactly that shape, so both of matbot's ambient carriers need this, and
8
+ * both had written it out: `runAs` for the principal, and the runner for the usage call site.
9
+ *
10
+ * The two copies did not agree. This one is the careful version: a **Proxy**, so the value crossing back
11
+ * out still IS what it was — a class-based iterator keeps its own members, its prototype and
12
+ * `instanceof` — with only the pull points replaced. The object-literal copy silently dropped all of
13
+ * that, which held only for as long as every tool was a plain async generator.
14
+ *
15
+ * Whether re-entering per pull is *sound* is the caller's question, not this helper's, and the answer
16
+ * differs: an identity is a re-entrant label, so `runAs` re-establishes it freely, while a hold or a
17
+ * roll-up settles when its `fn` does and must not be re-acquired per pull. See `machineBusy` and
18
+ * `withUsageScope`, which deliberately do not use this.
19
+ */
20
+
21
+ /** Run `f` inside the caller's ambient scope. The shape both `carrier.run(p, f)` and `withUsageSite(s, f)` take. */
22
+ export type ScopeFn = <R>(f: () => R) => R;
23
+
24
+ export function scopeIterator<T>(source: AsyncIterator<T>, inScope: ScopeFn): AsyncIterableIterator<T> {
25
+ const pulls: Record<PropertyKey, unknown> = {
26
+ [Symbol.asyncIterator]: () => proxy,
27
+ next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
28
+ ...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
29
+ ...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
30
+ };
31
+ const proxy: AsyncIterableIterator<T> = new Proxy(source, {
32
+ // `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
33
+ // off the lookup object instead of the iterator being wrapped.
34
+ get(target, prop) {
35
+ if (Object.hasOwn(pulls, prop)) return pulls[prop];
36
+ const value = Reflect.get(target, prop, target);
37
+ return typeof value === 'function' ? value.bind(target) : value;
38
+ },
39
+ }) as AsyncIterableIterator<T>;
40
+ return proxy;
41
+ }
42
+
43
+ /**
44
+ * The iterable form: every iterator this hands out is scoped per pull. For a caller holding an
45
+ * `AsyncIterable` that is not itself iterator-shaped — legal, and what a tool returning a bare
46
+ * `{ [Symbol.asyncIterator]() {…} }` gives the runner.
47
+ */
48
+ export function scopeIterable<T>(source: AsyncIterable<T>, inScope: ScopeFn): AsyncIterable<T> {
49
+ return { [Symbol.asyncIterator]: () => scopeIterator(source[Symbol.asyncIterator](), inScope) };
50
+ }
package/src/session.ts CHANGED
@@ -1,4 +1,31 @@
1
- import type { MessageContent, Session } from './types.js';
1
+ import type { Message, MessageContent, MessageRole, Session } from './types.js';
2
+
3
+ /**
4
+ * A `Message` with the fields every one carries minted for it — a fresh id, `createdAt` now — and the
5
+ * optional ones spread conditionally.
6
+ *
7
+ * Lives here rather than in core because a message is not core's to define: the hook registry builds
8
+ * marker messages in *this* package, and had its own copy of these five lines because core is
9
+ * downstream of it. There were five such copies in two packages, and every one of them is a place a
10
+ * message could quietly acquire a different shape.
11
+ */
12
+ export function createMessage(opts: {
13
+ role: MessageRole;
14
+ content: MessageContent[];
15
+ traceId: string;
16
+ providerName?: string;
17
+ metadata?: Record<string, unknown>;
18
+ }): Message {
19
+ return {
20
+ id: crypto.randomUUID(),
21
+ role: opts.role,
22
+ content: opts.content,
23
+ createdAt: new Date().toISOString(),
24
+ traceId: opts.traceId,
25
+ ...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
26
+ ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
27
+ };
28
+ }
2
29
 
3
30
  /**
4
31
  * A session's `updatedAt` invariant: the timestamp of its last message, or its own `createdAt` when it
@@ -195,7 +195,8 @@ export interface FollowupResult {
195
195
  * `context` is folded EPHEMERALLY onto the redo (for-this-answer-only). `durable` is instead folded
196
196
  * onto the re-run's user message — persisted, visible (mark it `origin: 'robo'`), carried live as a
197
197
  * `robo-user` event — so a `contextual`-kind correction updates the conversation durably even when it
198
- * lands post-commit. At least one of the two is present.
198
+ * lands post-commit. Normally at least one is supplied; the RETRACT is driven by the presence of this
199
+ * object, so `{}` means "pop and re-run with nothing added" rather than being quietly ignored.
199
200
  */
200
201
  retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
201
202
  /**
@@ -21,6 +21,7 @@ export interface KnowledgeIndex {
21
21
  * index never inspects an entry's opaque `source`. The party that indexed an entry owns retracting
22
22
  * it — e.g. a skill manager retracts a hidden/deleted skill rather than the index policing tenants. */
23
23
  remove(id: string): Promise<void>;
24
+ /** Rejects with the signal's reason if it is already aborted; an abort during the search may reject too. */
24
25
  search(terms: Array<{ term: string; context?: string }>, signal: AbortSignal): Promise<KnowledgeEntry[]>;
25
26
  /** Enumerate all indexed entries. When present, register('KnowledgeIndex', …) drains these into the incoming backend. */
26
27
  entries?(): Iterable<KnowledgeEntry>;