@smthrs/plugin 0.0.0-stage → 1.0.0-rc.13
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/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/dist/cjs/Config.d.ts +129 -0
- package/dist/cjs/Config.d.ts.map +1 -0
- package/dist/cjs/Config.js +115 -0
- package/dist/cjs/Config.js.map +7 -0
- package/dist/cjs/Hooks.d.ts +176 -0
- package/dist/cjs/Hooks.d.ts.map +1 -0
- package/dist/cjs/Hooks.js +34 -0
- package/dist/cjs/Hooks.js.map +7 -0
- package/dist/cjs/Kernel.d.ts +81 -0
- package/dist/cjs/Kernel.d.ts.map +1 -0
- package/dist/cjs/Kernel.js +57 -0
- package/dist/cjs/Kernel.js.map +7 -0
- package/dist/cjs/Plugin.d.ts +71 -0
- package/dist/cjs/Plugin.d.ts.map +1 -0
- package/dist/cjs/Plugin.js +25 -0
- package/dist/cjs/Plugin.js.map +7 -0
- package/dist/cjs/PluginError.d.ts +50 -0
- package/dist/cjs/PluginError.d.ts.map +1 -0
- package/dist/cjs/PluginError.js +58 -0
- package/dist/cjs/PluginError.js.map +7 -0
- package/dist/cjs/Plugins.d.ts +97 -0
- package/dist/cjs/Plugins.d.ts.map +1 -0
- package/dist/cjs/Plugins.js +169 -0
- package/dist/cjs/Plugins.js.map +7 -0
- package/dist/cjs/Resolve.d.ts +128 -0
- package/dist/cjs/Resolve.d.ts.map +1 -0
- package/dist/cjs/Resolve.js +346 -0
- package/dist/cjs/Resolve.js.map +7 -0
- package/dist/cjs/index.d.ts +83 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Boundary.d.ts +96 -0
- package/dist/cjs/internal/Boundary.d.ts.map +1 -0
- package/dist/cjs/internal/Boundary.js +129 -0
- package/dist/cjs/internal/Boundary.js.map +7 -0
- package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
- package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
- package/dist/cjs/internal/mergePluginLayers.js +57 -0
- package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +23 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/Config.d.ts +129 -0
- package/dist/esm/Config.d.ts.map +1 -0
- package/dist/esm/Config.js +160 -0
- package/dist/esm/Config.js.map +1 -0
- package/dist/esm/Hooks.d.ts +176 -0
- package/dist/esm/Hooks.d.ts.map +1 -0
- package/dist/esm/Hooks.js +56 -0
- package/dist/esm/Hooks.js.map +1 -0
- package/dist/esm/Kernel.d.ts +81 -0
- package/dist/esm/Kernel.d.ts.map +1 -0
- package/dist/esm/Kernel.js +58 -0
- package/dist/esm/Kernel.js.map +1 -0
- package/dist/esm/Plugin.d.ts +71 -0
- package/dist/esm/Plugin.d.ts.map +1 -0
- package/dist/esm/Plugin.js +20 -0
- package/dist/esm/Plugin.js.map +1 -0
- package/dist/esm/PluginError.d.ts +50 -0
- package/dist/esm/PluginError.d.ts.map +1 -0
- package/dist/esm/PluginError.js +53 -0
- package/dist/esm/PluginError.js.map +1 -0
- package/dist/esm/Plugins.d.ts +97 -0
- package/dist/esm/Plugins.d.ts.map +1 -0
- package/dist/esm/Plugins.js +190 -0
- package/dist/esm/Plugins.js.map +1 -0
- package/dist/esm/Resolve.d.ts +128 -0
- package/dist/esm/Resolve.d.ts.map +1 -0
- package/dist/esm/Resolve.js +369 -0
- package/dist/esm/Resolve.js.map +1 -0
- package/dist/esm/index.d.ts +83 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +48 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Boundary.d.ts +96 -0
- package/dist/esm/internal/Boundary.d.ts.map +1 -0
- package/dist/esm/internal/Boundary.js +162 -0
- package/dist/esm/internal/Boundary.js.map +1 -0
- package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
- package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
- package/dist/esm/internal/mergePluginLayers.js +41 -0
- package/dist/esm/internal/mergePluginLayers.js.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts +23 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js +64 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
- package/docs/README.md +133 -0
- package/docs/api.md +524 -0
- package/docs/concepts/configuration.md +111 -0
- package/docs/concepts/hook-kinds.md +107 -0
- package/docs/concepts/resolution.md +132 -0
- package/docs/guides/cache-identity.md +106 -0
- package/docs/guides/contribute-services.md +84 -0
- package/docs/guides/host-your-own-hooks.md +129 -0
- package/docs/guides/order-handlers.md +76 -0
- package/docs/guides/select-plugins.md +72 -0
- package/docs/guides/testing.md +149 -0
- package/docs/installation.md +75 -0
- package/docs/quickstart.md +152 -0
- package/docs/troubleshooting.md +180 -0
- package/package.json +179 -3
- package/src/Config.ts +207 -0
- package/src/Hooks.ts +212 -0
- package/src/Kernel.ts +119 -0
- package/src/Plugin.ts +80 -0
- package/src/PluginError.ts +62 -0
- package/src/Plugins.ts +271 -0
- package/src/Resolve.ts +539 -0
- package/src/index.ts +91 -0
- package/src/internal/Boundary.ts +252 -0
- package/src/internal/mergePluginLayers.ts +57 -0
- package/src/internal/snapshotCacheEnvironment.ts +78 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Configuration"
|
|
3
|
+
description: "The config waterfall and the JSON boundary it enforces: plugin-owned namespaces, reserved root keys, deep merge semantics, admission rules, and the published size limits."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Configuration is the one lifecycle the kernel owns. It is two hooks: `config`,
|
|
9
|
+
a waterfall in which each plugin may return a patch, and `configResolved`, a
|
|
10
|
+
parallel observer that sees the frozen result. `Kernel.make` runs both.
|
|
11
|
+
|
|
12
|
+
## Configuration is a namespace map, not a policy surface
|
|
13
|
+
|
|
14
|
+
A configuration is a flat record whose root keys name plugin-owned namespaces.
|
|
15
|
+
Each namespace holds JSON, and the kernel never interprets it: the plugin that
|
|
16
|
+
owns a namespace is the only code that knows what its contents mean.
|
|
17
|
+
|
|
18
|
+
Four root keys are refused with `config_invalid`: `engine`, `retry`, `store`,
|
|
19
|
+
and `plugins`. The kernel does not apply retry, storage, concurrency, cache,
|
|
20
|
+
wait, journal, or ownership policy, and accepting those names would imply that
|
|
21
|
+
setting them changes something. Those policies stay on the Effect service or
|
|
22
|
+
constructor option that applies them.
|
|
23
|
+
|
|
24
|
+
## The waterfall merges patches
|
|
25
|
+
|
|
26
|
+
Each `config` handler receives the accumulated configuration and returns a
|
|
27
|
+
partial to merge, or nothing:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { type FlowsPlugin, make } from "@smthrs/plugin"
|
|
31
|
+
import * as Effect from "effect/Effect"
|
|
32
|
+
|
|
33
|
+
const plugin: FlowsPlugin = make({
|
|
34
|
+
name: "flows-plugin-endpoint",
|
|
35
|
+
hooks: {
|
|
36
|
+
config: (config) =>
|
|
37
|
+
config["endpoint"] === undefined
|
|
38
|
+
? Effect.succeed({ endpoint: { url: "https://example.test" } })
|
|
39
|
+
: Effect.void
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`Config.merge` is the merge function `Kernel.make` uses. Records merge key by
|
|
45
|
+
key, recursively. Every other JSON value, including an array, replaces the
|
|
46
|
+
previous value wholesale:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
Config.merge({ a: { b: 1, c: 2 } }, { a: { c: 3, d: 4 } }) // { a: { b: 1, c: 3, d: 4 } }
|
|
50
|
+
Config.merge({ a: [1] }, { a: [2] }) // { a: [2] }
|
|
51
|
+
Config.merge({ a: { b: 1 } }, { a: 5 }) // { a: 5 }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Both operands and the merged result are admitted, so no handler in the chain
|
|
55
|
+
ever observes a value that breaks the rules below, even when each individual
|
|
56
|
+
patch was within them.
|
|
57
|
+
|
|
58
|
+
## What the boundary admits
|
|
59
|
+
|
|
60
|
+
Configuration is strict JSON, copied before any plugin observes it and frozen
|
|
61
|
+
recursively afterward. The following are refused with `config_invalid` and the
|
|
62
|
+
path of the offending value:
|
|
63
|
+
|
|
64
|
+
- Cycles and repeated object references.
|
|
65
|
+
- Sparse arrays, arrays with extra properties, and array subclasses.
|
|
66
|
+
- Accessors, symbol keys, non-enumerable properties, and exotic prototypes.
|
|
67
|
+
- Non-finite numbers, unpaired surrogates, and non-JSON values such as `Date`
|
|
68
|
+
and `Map`.
|
|
69
|
+
- The prototype-control keys `__proto__`, `constructor`, and `prototype`, at
|
|
70
|
+
every depth.
|
|
71
|
+
- `undefined` members.
|
|
72
|
+
|
|
73
|
+
The last one is a decision worth naming. `{ endpoint: undefined }` fails at
|
|
74
|
+
`$.endpoint` instead of resolving to `{}`. A silent drop hid a typo behind a
|
|
75
|
+
value the next handler never saw. To leave something unset, omit the key or
|
|
76
|
+
write `null`.
|
|
77
|
+
|
|
78
|
+
Admission reads a value through `Reflect.ownKeys`, `Object.getPrototypeOf`, and
|
|
79
|
+
one property descriptor per key. An accessor is never called, so a proxy sees a
|
|
80
|
+
bounded number of traps and only the data those traps return is copied. A trap
|
|
81
|
+
that throws refuses the whole tree.
|
|
82
|
+
|
|
83
|
+
## Size limits
|
|
84
|
+
|
|
85
|
+
| Limit | Value | Export |
|
|
86
|
+
| ------------------------------- | ----- | ----------------------------- |
|
|
87
|
+
| Encoded bytes per configuration | 1 MiB | `Config.maximumConfigBytes` |
|
|
88
|
+
| Container nesting depth | 64 | `Config.maximumConfigDepth` |
|
|
89
|
+
| Aggregate members | 4,096 | `Config.maximumConfigMembers` |
|
|
90
|
+
| Aggregate values | 8,192 | `Config.maximumConfigNodes` |
|
|
91
|
+
|
|
92
|
+
Strings are limited to 64 KiB of encoded JSON and keys to 1 KiB. A tree over any
|
|
93
|
+
bound fails with `config_invalid`, and because the merged result is admitted
|
|
94
|
+
too, a chain of small patches cannot add up past them.
|
|
95
|
+
|
|
96
|
+
## configResolved is a lossy observer boundary
|
|
97
|
+
|
|
98
|
+
After the waterfall settles, `Kernel.make` decodes and freezes the result, then
|
|
99
|
+
runs every `configResolved` handler in parallel. Those handlers are observers:
|
|
100
|
+
their success values are discarded, and their failures do not fail startup.
|
|
101
|
+
Read them from `kernel.observerErrors` and log them at your own boundary:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const kernel = await Effect.runPromise(Kernel.make([plugin]))
|
|
105
|
+
for (const error of kernel.observerErrors) {
|
|
106
|
+
console.warn(`plugin ${error.plugin} failed to observe the config: ${error.code}`)
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Dropping them on the floor is the one thing to avoid. A plugin whose observer
|
|
111
|
+
fails silently looks configured and is not.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Hook kinds"
|
|
3
|
+
description: "The four dispatch shapes a hook can declare, what each promises about ordering, results, and failure, and how the type-level kind and the runtime catalog stay in agreement."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A hook is a name with a fixed dispatch shape. The shape is called its **kind**,
|
|
9
|
+
and the kernel fixes the four kinds: `sequential`, `parallel`, `first`, and
|
|
10
|
+
`waterfall`. A host picks a kind when it declares a hook, and that choice
|
|
11
|
+
decides how many handlers run, in what order, what the caller gets back, and
|
|
12
|
+
what a failing handler does to the caller.
|
|
13
|
+
|
|
14
|
+
## A hook entry is a handler or an ordering object
|
|
15
|
+
|
|
16
|
+
A plugin declares a hook either as a bare function or as Vite's per-hook
|
|
17
|
+
ordering object:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { type FlowsPlugin, make } from "@smthrs/plugin"
|
|
21
|
+
import * as Effect from "effect/Effect"
|
|
22
|
+
|
|
23
|
+
const bare: FlowsPlugin = make({
|
|
24
|
+
name: "flows-plugin-bare",
|
|
25
|
+
hooks: { configResolved: () => Effect.void }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const ordered: FlowsPlugin = make({
|
|
29
|
+
name: "flows-plugin-ordered",
|
|
30
|
+
hooks: { configResolved: { order: "pre", handler: () => Effect.void } }
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Both forms are the same handler to the dispatcher. `Hooks.handlerOf` and
|
|
35
|
+
`Hooks.orderOf` read either one. `undefined` and `null` are refused rather than
|
|
36
|
+
skipped, so a conditionally built plugin fails at startup with a path instead of
|
|
37
|
+
silently declaring nothing.
|
|
38
|
+
|
|
39
|
+
## What each kind promises
|
|
40
|
+
|
|
41
|
+
| Kind | Which handlers run | What the caller receives | What a failing handler does |
|
|
42
|
+
| ------------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `sequential` | Every handler, in resolved order, one at a time. No two overlap. | An array of every handler's success value, in resolved order. | Stops dispatch at that handler and fails the caller with `hook_failed`. |
|
|
44
|
+
| `parallel` | Every handler, concurrently, bounded by `parallelConcurrency` (16 by default). | An array of `PluginError`, in resolved handler order. Success values are discarded. | Nothing. The failure is returned, and the caller never fails. |
|
|
45
|
+
| `first` | Handlers in resolved order until one answers `Option.some`. | That `Option`, or `Option.none()` when no handler answered. | Stops dispatch and fails the caller with `hook_failed`. A non-`Option` value fails with `invalid_hook_result`. |
|
|
46
|
+
| `waterfall` | Every handler, each one seeing the value the previous handler produced. | The final value. | Stops dispatch and fails the caller with `hook_failed`. |
|
|
47
|
+
|
|
48
|
+
Choose by what the caller needs. Use `sequential` when every handler must run
|
|
49
|
+
and the caller reads every answer. Use `parallel` for observers whose failure is
|
|
50
|
+
not the caller's problem. Use `first` when the first plugin with an opinion
|
|
51
|
+
wins and the host has its own default for silence. Use `waterfall` when each
|
|
52
|
+
handler transforms a value the next one sees.
|
|
53
|
+
|
|
54
|
+
## Waterfall handlers return a patch, not the whole value
|
|
55
|
+
|
|
56
|
+
A waterfall dispatch takes a merge function alongside the initial value, and the
|
|
57
|
+
merge decides what a handler's answer means:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const merged = kernel.plugins.waterfall("config", initial, Config.merge)
|
|
61
|
+
const replaced = kernel.plugins.waterfall("tools", initial, (_previous, next) => next)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`Config.merge` deep-merges each returned patch into the accumulated
|
|
65
|
+
configuration, which is what makes the `config` hook a patch protocol. A host
|
|
66
|
+
that wants replacement passes `(_previous, next) => next`, which is what the
|
|
67
|
+
Smithers agent host in [`@smthrs/agent`](/api/agent) does for its registry,
|
|
68
|
+
flow, and model-request waterfalls. A handler that returns `undefined` (an
|
|
69
|
+
`Effect.void` handler) leaves the value untouched: the merge is not called at
|
|
70
|
+
all.
|
|
71
|
+
|
|
72
|
+
## The kind lives in the type and in the catalog
|
|
73
|
+
|
|
74
|
+
`SequentialHook`, `ParallelHook`, `FirstHook`, and `WaterfallHook` are phantom
|
|
75
|
+
carriers. They add nothing at runtime; they record the kind and the handler
|
|
76
|
+
signature in the type system so the dispatcher's methods can accept only the
|
|
77
|
+
hook names of their own kind:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
declare module "@smthrs/plugin" {
|
|
81
|
+
interface FlowsHooks {
|
|
82
|
+
readonly toolCall: SequentialHook<(ctx: ToolCallContext) => Effect.Effect<Option.Option<ToolOverride>>>
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Calling `dispatcher.parallel("toolCall", ctx)` does not compile, because
|
|
88
|
+
`KeysOfKind<FlowsHooks, "parallel">` does not contain `toolCall`.
|
|
89
|
+
|
|
90
|
+
The type system cannot enumerate an augmentable interface, so the kernel also
|
|
91
|
+
needs the same information at runtime. That is the hook catalog a host passes as
|
|
92
|
+
`Resolve.Options.hooks`: a record from hook name to kind, typed as
|
|
93
|
+
`HookCatalog<H>` so a name the interface declares cannot carry another kind.
|
|
94
|
+
It is what the `unknown_hook` guard checks a plugin against, and it is what
|
|
95
|
+
[Declare hooks for your host](../guides/host-your-own-hooks.md) walks through.
|
|
96
|
+
Resolution keeps the admitted catalog as `Resolved.kinds`, refuses a catalog
|
|
97
|
+
that labels `config` or `configResolved` with another kind, and every dispatch
|
|
98
|
+
method refuses a hook whose catalog kind differs from its own with
|
|
99
|
+
`hook_kind_mismatch` before running a handler.
|
|
100
|
+
|
|
101
|
+
## Cancellation is fiber interruption
|
|
102
|
+
|
|
103
|
+
Nothing in the dispatcher threads an `AbortSignal`. Interrupting the fiber that
|
|
104
|
+
runs a dispatch interrupts the handler in flight, runs its finalizers, and skips
|
|
105
|
+
every handler that had not started, for both `sequential` and bounded `parallel`
|
|
106
|
+
dispatch. A handler that needs cleanup writes it with `Effect.onInterrupt` or
|
|
107
|
+
`Effect.acquireRelease` like any other Effect.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Resolution"
|
|
3
|
+
description: "How a plugin list becomes an ordered immutable catalog: the order of the checks, Vite's two-level ordering rule, exact-Unicode names, the copies the kernel takes, and the published limits."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Resolution is the step between "here is an array of plugins" and "here is a
|
|
9
|
+
dispatcher". It happens once, at startup, and everything it produces is a copy
|
|
10
|
+
the kernel owns. `Resolve.resolve` does the work; `Kernel.make` calls it for
|
|
11
|
+
you.
|
|
12
|
+
|
|
13
|
+
## The order of the checks
|
|
14
|
+
|
|
15
|
+
The steps run in this order, and the order is part of the contract:
|
|
16
|
+
|
|
17
|
+
1. **Snapshot the options.** `target`, `hooks`, `parallelConcurrency`,
|
|
18
|
+
`cacheEnvironment`, and `config` are read through property descriptors. An
|
|
19
|
+
unknown option key is `invalid_plugin`.
|
|
20
|
+
2. **Flatten the input.** Nested arrays flatten within the depth and node
|
|
21
|
+
limits, and `false`, `null`, and `undefined` entries drop. Array length must
|
|
22
|
+
fit the remaining node budget, including queued entries, before keys are
|
|
23
|
+
enumerated or element descriptors are read. A preset is therefore just a
|
|
24
|
+
function that returns plugins.
|
|
25
|
+
3. **Validate every plugin record.** Each plugin, each hook entry, and each
|
|
26
|
+
ordering object is checked for shape and copied. This happens _before_
|
|
27
|
+
`apply` filtering, so an excluded plugin cannot hide a malformed one.
|
|
28
|
+
4. **Filter by `apply`.** A literal `"engine"` or `"harness"` is compared with
|
|
29
|
+
the host's `target`. A predicate is called with the pre-resolution config.
|
|
30
|
+
5. **Check hook names against the host catalog.** This happens _after_
|
|
31
|
+
selection, so a shared preset can carry `apply: "harness"` plugins with
|
|
32
|
+
harness-only hooks and still resolve under a bare engine kernel.
|
|
33
|
+
6. **Reject duplicate names.** Two selected plugins with the same name fail with
|
|
34
|
+
`duplicate_name` rather than the last one silently winning.
|
|
35
|
+
7. **Order the list and build the handler map.**
|
|
36
|
+
8. **Decode the cache environment**, when the host declared one. See
|
|
37
|
+
[Declare a cache identity](../guides/cache-identity.md).
|
|
38
|
+
|
|
39
|
+
## Ordering follows Vite exactly
|
|
40
|
+
|
|
41
|
+
Ordering has two levels, and they are applied in the opposite order from the one
|
|
42
|
+
most readers expect.
|
|
43
|
+
|
|
44
|
+
`enforce` sorts the plugin list once, into `pre`, then normal, then `post`,
|
|
45
|
+
stably within each group. That sorted list is the resolved plugin order, and it
|
|
46
|
+
is the order `kernel.layer` merges layers in.
|
|
47
|
+
|
|
48
|
+
The per-hook `order` then re-partitions that list, for one hook only. A handler
|
|
49
|
+
declared `{ order: "pre", handler }` runs ahead of every normal-order handler
|
|
50
|
+
even when its plugin is `enforce: "post"`:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const plugins = [
|
|
54
|
+
make({ name: "post-plugin-pre-hook", enforce: "post", hooks: { configResolved: { order: "pre", handler } } }),
|
|
55
|
+
make({ name: "pre-plugin-post-hook", enforce: "pre", hooks: { configResolved: { order: "post", handler } } })
|
|
56
|
+
]
|
|
57
|
+
// configResolved runs post-plugin-pre-hook first.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Ties keep the resolved plugin order. Because the per-hook `order` applies to one
|
|
61
|
+
hook, a plugin can be early for one hook and late for another. See
|
|
62
|
+
[Control the order handlers run in](../guides/order-handlers.md) for how to pick
|
|
63
|
+
between the two levers.
|
|
64
|
+
|
|
65
|
+
## Names compare as exact Unicode
|
|
66
|
+
|
|
67
|
+
A plugin name is a bounded, non-empty, control-free, well-formed string of at
|
|
68
|
+
most 256 UTF-16 code units, and it is never normalized. Two canonically
|
|
69
|
+
equivalent spellings are two different plugins, and a name that is only
|
|
70
|
+
whitespace is refused. The same rules apply to `version` and to hook names in a
|
|
71
|
+
host catalog and to keys declared in a plugin's `hooks` record. Invalid
|
|
72
|
+
plugin-declared hook names fail with `invalid_plugin` before `apply` filtering,
|
|
73
|
+
using the bounded path `.hooks[N]`, where `N` is the key's zero-based index.
|
|
74
|
+
|
|
75
|
+
The naming convention is `flows-plugin-<thing>`, following Vite's
|
|
76
|
+
`vite-plugin-<thing>`. The kernel does not enforce it.
|
|
77
|
+
|
|
78
|
+
## Everything is copied
|
|
79
|
+
|
|
80
|
+
The resolved catalog owns every record it exposes. Dispatch never reads a
|
|
81
|
+
caller-owned map, plugin record, hook object, config value, or cache identity:
|
|
82
|
+
|
|
83
|
+
- Plugin records and hook objects are copied into frozen objects.
|
|
84
|
+
- The handler list for each hook is a frozen array of frozen records, each one
|
|
85
|
+
carrying its plugin name and hook name for attribution.
|
|
86
|
+
- `Resolved.handlers` is a read-only map facade with no `set`, `delete`, or
|
|
87
|
+
`clear`.
|
|
88
|
+
- The configuration `apply` predicates see is a snapshot taken before the first
|
|
89
|
+
predicate runs.
|
|
90
|
+
|
|
91
|
+
Reflection is descriptor-only. Accessors are never executed, an exotic prototype
|
|
92
|
+
is refused, and a hostile proxy observes a bounded number of traps whose return
|
|
93
|
+
values are copied rather than retained. Mutating a plugin object after startup
|
|
94
|
+
changes nothing about dispatch.
|
|
95
|
+
|
|
96
|
+
## Limits
|
|
97
|
+
|
|
98
|
+
One kernel accepts:
|
|
99
|
+
|
|
100
|
+
| Limit | Value | Export |
|
|
101
|
+
| ----------------------------------------------------------- | ----- | ------------------------------------ |
|
|
102
|
+
| Plugins | 256 | `Resolve.maximumPlugins` |
|
|
103
|
+
| Hook handlers | 1,024 | `Resolve.maximumHandlers` |
|
|
104
|
+
| Input nodes, including arrays and dropped entries | 4,096 | `Resolve.maximumPluginInputNodes` |
|
|
105
|
+
| Nested preset-array depth | 64 | `Resolve.maximumPluginDepth` |
|
|
106
|
+
| Plugin, version, and hook name length, in UTF-16 code units | 256 | `Resolve.maximumPluginNameLength` |
|
|
107
|
+
| Parallel observers at once, by default | 16 | `Resolve.defaultParallelConcurrency` |
|
|
108
|
+
| Parallel observers at once, at most | 256 | `Resolve.maximumParallelConcurrency` |
|
|
109
|
+
|
|
110
|
+
Exceeding a count or depth limit fails with `resource_limit` and the path of the
|
|
111
|
+
offending entry or preset array. Invalid names fail with `invalid_plugin`.
|
|
112
|
+
The handler bound counts the handlers the kernel dispatches, so a plugin whose
|
|
113
|
+
hooks were filtered out costs nothing against it.
|
|
114
|
+
|
|
115
|
+
Resolution diagnostics truncate property and unknown-hook key text after 64
|
|
116
|
+
UTF-16 code units, appending `...`. Paths use dot notation for identifiers and
|
|
117
|
+
JSON-quoted brackets for other keys; unknown-hook messages also JSON-quote the
|
|
118
|
+
key. The `hook` error field retains the complete, validated hook identity.
|
|
119
|
+
|
|
120
|
+
Configuration and cache-identity JSON admission also bounds strings and diagnostic paths:
|
|
121
|
+
|
|
122
|
+
| Limit | Value |
|
|
123
|
+
| ------------------------------------------------------------ | ----------------- |
|
|
124
|
+
| One JSON-encoded string, including quotes and escapes | 64 KiB |
|
|
125
|
+
| One JSON-encoded property name, including quotes and escapes | 1 KiB |
|
|
126
|
+
| Refusal path, as a JSON-encoded string | At most 192 bytes |
|
|
127
|
+
|
|
128
|
+
String and key lengths are checked before scanning or encoding oversized text.
|
|
129
|
+
Oversized or ill-formed property names use the parent path plus `[key:N]`, where
|
|
130
|
+
`N` is the zero-based index in the record's own-key order. Longer refusal paths
|
|
131
|
+
retain a prefix ending in `...`; truncation preserves Unicode characters and
|
|
132
|
+
accounts for JSON escaping. Short paths for admitted keys keep their usual shape.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Declare a cache identity for sealed steps"
|
|
3
|
+
description: "Supply Action.CacheEnvironment so a composition's sealed cache keys are shareable across runs, and understand why every selected plugin then needs a version."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 5
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Skip this guide unless your host runs on the Smithers durable engine, which is
|
|
9
|
+
what reads the identity described here. Everything else in this package works
|
|
10
|
+
without it.
|
|
11
|
+
|
|
12
|
+
A sealed step in the [durable engine](/api/engine) is content-addressed: the
|
|
13
|
+
same call replays one recorded result instead of paying the provider again.
|
|
14
|
+
That is only safe when the key covers the composition the result was computed
|
|
15
|
+
under. Swap a model plugin and keep the key, and the run is served a stale
|
|
16
|
+
answer computed by a different composition.
|
|
17
|
+
|
|
18
|
+
The kernel is where a host says what its composition is. Declaring
|
|
19
|
+
`cacheEnvironment` folds every selected plugin's identity into the environment
|
|
20
|
+
that keys sealed steps. Omitting it keeps sealed keys local to the run.
|
|
21
|
+
|
|
22
|
+
## Omitting it is the honest default
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Kernel, make } from "@smthrs/plugin"
|
|
26
|
+
|
|
27
|
+
const kernel = Kernel.make([make({ name: "flows-plugin-model-sonnet" })])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`Action.CurrentCacheEnvironment` resolves to `undefined` under this kernel's
|
|
31
|
+
layer, which is the engine's "nothing was declared" state. Sealed keys are
|
|
32
|
+
pinned to the current execution, so nothing is shared and nothing is wrong. Use
|
|
33
|
+
this until you can state the composition's whole identity.
|
|
34
|
+
|
|
35
|
+
## Declaring it requires the complete identity
|
|
36
|
+
|
|
37
|
+
Pass the additional layer identities your host built outside the plugin list and
|
|
38
|
+
the capability envelope the composition actually holds:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const kernel = Kernel.make(
|
|
42
|
+
[make({ name: "flows-plugin-model-sonnet", version: "1.4.0" })],
|
|
43
|
+
{},
|
|
44
|
+
{ cacheEnvironment: { layers: ["Host=node"], capabilities: { fs: ["/workspace/**"] } } }
|
|
45
|
+
)
|
|
46
|
+
// CurrentCacheEnvironment:
|
|
47
|
+
// { layers: ["flows-plugin-model-sonnet@1.4.0", "Host=node"], capabilities: { fs: ["/workspace/**"] } }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The kernel prepends each selected plugin as `name@version`, in resolved order,
|
|
51
|
+
ahead of the layers you supplied. Change a plugin's version and the declared
|
|
52
|
+
layers change, so the sealed keys change with it.
|
|
53
|
+
|
|
54
|
+
`Action.CacheEnvironment` is complete by contract: it is the whole authority and
|
|
55
|
+
composition envelope, not a hint. Supply the capabilities the composition really
|
|
56
|
+
grants. A result computed under a broad envelope must not be served to a run
|
|
57
|
+
with an attenuated one.
|
|
58
|
+
|
|
59
|
+
## Every selected plugin needs a version
|
|
60
|
+
|
|
61
|
+
Declaring a cache environment makes `version` required on every plugin the
|
|
62
|
+
kernel selected:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const refused = Kernel.make(
|
|
66
|
+
[make({ name: "flows-plugin-model-sonnet" })],
|
|
67
|
+
{},
|
|
68
|
+
{ cacheEnvironment: { layers: [], capabilities: {} } }
|
|
69
|
+
)
|
|
70
|
+
// Fails with PluginError { code: "cache_environment_invalid", plugin: "flows-plugin-model-sonnet", path: "$[0].version" }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A versionless plugin is refused rather than folded in as a bare name, because a
|
|
74
|
+
bare name claims that two different builds of that plugin are the same
|
|
75
|
+
composition. The refusal names the plugin, so the fix is to add its version or
|
|
76
|
+
to drop the cache environment.
|
|
77
|
+
|
|
78
|
+
## Identity is injective
|
|
79
|
+
|
|
80
|
+
Both halves of `name@version` escape `%` first and then `@`, so the joined
|
|
81
|
+
string can be split back apart:
|
|
82
|
+
|
|
83
|
+
| Plugin name | Version | Layer entry |
|
|
84
|
+
| ------------------ | ------- | ---------------------- |
|
|
85
|
+
| `flows-plugin-a` | `1.0.0` | `flows-plugin-a@1.0.0` |
|
|
86
|
+
| `flows-plugin-a@b` | `c` | `flows-plugin-a%40b@c` |
|
|
87
|
+
| `flows-plugin-a` | `b@c` | `flows-plugin-a@b%40c` |
|
|
88
|
+
| `@smthrs/x` | `1.0.0` | `%40smthrs/x@1.0.0` |
|
|
89
|
+
|
|
90
|
+
An ordinary identity stays readable, and two compositions that differ only in
|
|
91
|
+
where the delimiter fell never collapse onto one sealed identity.
|
|
92
|
+
|
|
93
|
+
## The value is copied before the engine sees it
|
|
94
|
+
|
|
95
|
+
Resolution decodes the environment against the `Action.CacheEnvironment` schema,
|
|
96
|
+
copies and freezes every layer entry, capability name, and capability array, and
|
|
97
|
+
only then provides it. Invalid or mutable caller data never reaches
|
|
98
|
+
`Action.CurrentCacheEnvironment`. A malformed environment fails with
|
|
99
|
+
`cache_environment_invalid` and the option path.
|
|
100
|
+
|
|
101
|
+
## Plugins cannot replace it
|
|
102
|
+
|
|
103
|
+
The kernel layer seals `Action.CurrentCacheEnvironment` after merging plugin
|
|
104
|
+
layers. A plugin layer that provides `Action.layerCacheEnvironment` is
|
|
105
|
+
overridden by the declared environment, or by `undefined` when the host
|
|
106
|
+
declared none, so no plugin can forge or declare the composition's identity.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Contribute services from a plugin"
|
|
3
|
+
description: "Attach an Effect layer to a plugin, understand the merge order and what happens when two plugins provide the same tag, and handle a layer that fails to build."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 4
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A hook lets a plugin answer a question the host asks. A `layer` lets a plugin
|
|
9
|
+
add a service the rest of the composition can ask for. Declare both on the same
|
|
10
|
+
record when a plugin needs both.
|
|
11
|
+
|
|
12
|
+
## Attach a layer
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Kernel, make } from "@smthrs/plugin"
|
|
16
|
+
import * as Context from "effect/Context"
|
|
17
|
+
import * as Effect from "effect/Effect"
|
|
18
|
+
import * as Layer from "effect/Layer"
|
|
19
|
+
|
|
20
|
+
class Clock extends Context.Service<Clock, { readonly now: () => number }>()("example/Clock") {}
|
|
21
|
+
|
|
22
|
+
const clock = make({
|
|
23
|
+
name: "flows-plugin-clock",
|
|
24
|
+
layer: Layer.succeed(Clock)({ now: () => Date.now() })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const program = Effect.gen(function*() {
|
|
28
|
+
const kernel = yield* Kernel.make([clock])
|
|
29
|
+
return yield* Clock.pipe(
|
|
30
|
+
Effect.map((service) => service.now()),
|
|
31
|
+
Effect.provide(kernel.layer)
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`kernel.layer` is every selected plugin's layer merged into one. A plugin with
|
|
37
|
+
no `layer` contributes nothing, and a plugin list with no layers at all produces
|
|
38
|
+
an empty layer that is still safe to provide.
|
|
39
|
+
|
|
40
|
+
The value must be an Effect `Layer`. Anything else fails resolution with
|
|
41
|
+
`invalid_plugin` at `$.layer`, before the layer is ever built.
|
|
42
|
+
|
|
43
|
+
## Merge order follows resolved plugin order
|
|
44
|
+
|
|
45
|
+
Layers merge left to right in the resolved plugin order, so a service an earlier
|
|
46
|
+
plugin provides is visible to a later plugin's layer. `enforce: "pre"` is
|
|
47
|
+
therefore how a plugin makes sure its service is available to the plugins that
|
|
48
|
+
need it. See [Control the order handlers run in](./order-handlers.md).
|
|
49
|
+
|
|
50
|
+
When two plugins provide the same service tag, the later resolved plugin wins.
|
|
51
|
+
There is no collision error: last one in resolved order is the value the
|
|
52
|
+
composition sees. If two plugins genuinely conflict, decide it with `enforce`,
|
|
53
|
+
or with [`apply`](./select-plugins.md) so only one of them is selected.
|
|
54
|
+
|
|
55
|
+
## Handle a layer that fails to build
|
|
56
|
+
|
|
57
|
+
A layer's failure is wrapped as `layer_failed`, attributed to the plugin, with
|
|
58
|
+
the original cause on `cause`:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const failing = make({
|
|
62
|
+
name: "flows-plugin-remote",
|
|
63
|
+
layer: Layer.effectDiscard(Effect.fail("no credentials"))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const build = Effect.gen(function*() {
|
|
67
|
+
const kernel = yield* Kernel.make([failing])
|
|
68
|
+
return yield* Effect.void.pipe(Effect.provide(kernel.layer))
|
|
69
|
+
})
|
|
70
|
+
// Fails with PluginError { code: "layer_failed", plugin: "flows-plugin-remote" }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The failure arrives when the layer is built, not when the kernel is made:
|
|
74
|
+
`Kernel.make` validates that the value is a layer and composes it, and building
|
|
75
|
+
is the caller's step. A composition that never provides `kernel.layer` never
|
|
76
|
+
sees the failure.
|
|
77
|
+
|
|
78
|
+
## Layers are not the place for engine policy
|
|
79
|
+
|
|
80
|
+
A plugin layer contributes services to the composition the host assembled. It is
|
|
81
|
+
not a way to reach into durable execution policy: retry, storage, concurrency,
|
|
82
|
+
cache, wait, journal, and ownership stay on the components that apply them, and
|
|
83
|
+
the kernel refuses configuration keys that would suggest otherwise. Contribute a
|
|
84
|
+
service; let the host decide what to do with it.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Declare hooks for your host"
|
|
3
|
+
description: "Add extension points of your own: augment FlowsHooks, supply the matching runtime catalog, resolve for your target, and dispatch only what you declared."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
The kernel dispatches two hooks of its own. Every other extension point is
|
|
9
|
+
yours to declare. Adding one takes three pieces that must agree: a type
|
|
10
|
+
declaration, a runtime catalog entry, and a dispatch call.
|
|
11
|
+
|
|
12
|
+
## Declare the hooks in the type system
|
|
13
|
+
|
|
14
|
+
Augment `FlowsHooks` in the root module. The specifier must be
|
|
15
|
+
`"@smthrs/plugin"`, because that is where the interface is declared:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import type { ParallelHook, WaterfallHook } from "@smthrs/plugin"
|
|
19
|
+
import type * as Effect from "effect/Effect"
|
|
20
|
+
|
|
21
|
+
interface Prompt {
|
|
22
|
+
readonly system: ReadonlyArray<string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Session {
|
|
26
|
+
readonly id: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare module "@smthrs/plugin" {
|
|
30
|
+
interface FlowsHooks {
|
|
31
|
+
/** Rewrites the prompt before the host sends it. */
|
|
32
|
+
readonly promptRewrite: WaterfallHook<(prompt: Prompt) => Effect.Effect<Prompt | void>>
|
|
33
|
+
/** Observes the start of a session. */
|
|
34
|
+
readonly sessionStart: ParallelHook<(session: Session) => Effect.Effect<void>>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Pick the kind deliberately: it decides what the caller gets back and what a
|
|
40
|
+
failing handler does. See [Hook kinds](../concepts/hook-kinds.md).
|
|
41
|
+
|
|
42
|
+
## Supply the matching runtime catalog
|
|
43
|
+
|
|
44
|
+
The type system cannot enumerate an augmentable interface, so the kernel needs
|
|
45
|
+
the same names again as data. Spread `engineHooks` so the config lifecycle stays
|
|
46
|
+
available to plugins:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { engineHooks } from "@smthrs/plugin"
|
|
50
|
+
|
|
51
|
+
export const hooks = Object.freeze(
|
|
52
|
+
{
|
|
53
|
+
...engineHooks,
|
|
54
|
+
promptRewrite: "waterfall",
|
|
55
|
+
sessionStart: "parallel"
|
|
56
|
+
} as const
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This catalog is what the `unknown_hook` guard checks a plugin against. A plugin
|
|
61
|
+
that declares a hook you did not list fails at startup with the plugin name, the
|
|
62
|
+
hook name, and the path, rather than declaring a handler nobody will ever call.
|
|
63
|
+
|
|
64
|
+
## Resolve for your host
|
|
65
|
+
|
|
66
|
+
Pass the catalog, and pass `target: "harness"` unless your host is the engine:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { type FlowsHooks, Kernel, type PluginInput } from "@smthrs/plugin"
|
|
70
|
+
import type { FlowsConfig } from "@smthrs/plugin/Config"
|
|
71
|
+
|
|
72
|
+
export const makeKernel = (plugins: PluginInput<FlowsHooks> = [], config: FlowsConfig = {}) =>
|
|
73
|
+
Kernel.make<FlowsHooks>(plugins, config, { target: "harness", hooks })
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`target` defaults to `"engine"`, so a plugin declared `apply: "harness"` is
|
|
77
|
+
dropped by a bare `Kernel.make`. See
|
|
78
|
+
[Include a plugin conditionally](./select-plugins.md).
|
|
79
|
+
|
|
80
|
+
## Dispatch only what you declared
|
|
81
|
+
|
|
82
|
+
Give each hook a named dispatch function, so callers do not repeat the merge
|
|
83
|
+
function and the hook name:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import type * as Plugins from "@smthrs/plugin/Plugins"
|
|
87
|
+
|
|
88
|
+
export const rewritePrompt = (plugins: Plugins.Service<FlowsHooks>, initial: Prompt) =>
|
|
89
|
+
plugins.waterfall("promptRewrite", initial, (_previous, next) => next)
|
|
90
|
+
|
|
91
|
+
export const sessionStarted = (plugins: Plugins.Service<FlowsHooks>, session: Session) =>
|
|
92
|
+
plugins.parallel("sessionStart", session)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Dispatch is closed: the kernel runs only the catalog you supplied, and a
|
|
96
|
+
dispatcher method accepts only hook names of its own kind. Nothing dispatches a
|
|
97
|
+
hook on your behalf.
|
|
98
|
+
|
|
99
|
+
## When to use a standalone interface instead
|
|
100
|
+
|
|
101
|
+
Module augmentation gives one process-wide catalog. That is the right shape for
|
|
102
|
+
a host that owns its process, and it is what the shared `Plugins` service tag
|
|
103
|
+
and its layers hold.
|
|
104
|
+
|
|
105
|
+
A host that must not widen the shared catalog declares its own interface and
|
|
106
|
+
carries the dispatcher itself:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import type { SequentialHook } from "@smthrs/plugin"
|
|
110
|
+
import * as Plugins from "@smthrs/plugin/Plugins"
|
|
111
|
+
import * as Resolve from "@smthrs/plugin/Resolve"
|
|
112
|
+
import * as Effect from "effect/Effect"
|
|
113
|
+
|
|
114
|
+
interface IsolatedHooks {
|
|
115
|
+
readonly isolated: SequentialHook<(value: number) => Effect.Effect<number>>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const program = Effect.gen(function*() {
|
|
119
|
+
const resolved = yield* Resolve.resolve<IsolatedHooks>(
|
|
120
|
+
{ name: "flows-plugin-double", hooks: { isolated: (value) => Effect.succeed(value * 2) } },
|
|
121
|
+
{ hooks: { isolated: "sequential" } }
|
|
122
|
+
)
|
|
123
|
+
return yield* Plugins.make<IsolatedHooks>(resolved).sequential("isolated", 21)
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A `Plugins.Service<IsolatedHooks>` held directly does not use the shared
|
|
128
|
+
`Plugins` tag, `Plugins.layer`, or `Plugins.layerNoop`. Those three target the
|
|
129
|
+
augmented `FlowsHooks` catalog only.
|