@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +169 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +346 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +23 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +190 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +369 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +23 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +64 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +133 -0
  94. package/docs/api.md +524 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +75 -0
  105. package/docs/quickstart.md +152 -0
  106. package/docs/troubleshooting.md +180 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +271 -0
  114. package/src/Resolve.ts +539 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. 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.