@lambdot/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#12](https://github.com/Embers-of-the-Fire/lambdot/pull/12) [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Add `Composite.expose(name)`: seal a kernel chain into a final, named `Engine` artifact. The engine preserves the chain's external input requirement, erases `bind`-encapsulated internals from its type, drops the composition methods (`use`/`bind` throw at runtime once exposed), and wires into a supervisor kernel under its new name.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#12](https://github.com/Embers-of-the-Fire/lambdot/pull/12) [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Implement `Engine.apply` on the runtime behind `Composite.expose`: the sealed engine now delegates to its inner composition, so calling `engine.apply(input, scope, config)` directly works instead of throwing `TypeError: engine.apply is not a function`.
12
+
13
+ ## 0.1.1
14
+
15
+ ### Patch Changes
16
+
17
+ - [#10](https://github.com/Embers-of-the-Fire/lambdot/pull/10) [`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Switch inter-package dependency pins from exact versions to `workspace:*` so workspace members always resolve against local sources during development; pnpm rewrites the protocol to exact versions at pack/publish time.
18
+
3
19
  ## [0.1.0](https://github.com/Embers-of-the-Fire/lambdot/compare/core-v0.0.1...core-v0.1.0) (2026-08-29)
4
20
 
5
21
 
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # @lambdot/core
2
+
3
+ The lambdot kernel: a stateless, serverless, non-context-aware composition
4
+ runtime for chatbots. It owns no conversational data and no platform
5
+ semantics — inputs, outputs, state backends, and features are all plugins,
6
+ composed through TypeScript's type system. A plugin is a function:
7
+ `apply(input, scope, config)` maps a declared input record to an output
8
+ value. Every `use(...)`/`bind(...)` wires the next plugin's input with a
9
+ `mapping` from the namespaces visible so far, so wiring a plugin before its
10
+ dependencies is a compile error. Published on npm as `@lambdot/core`.
11
+
12
+ ## Concepts
13
+
14
+ - **A plugin is a function.** `apply(input, scope, config)` receives exactly
15
+ the input record it declares and returns the output value it emits. There
16
+ are no plugin roles and no lifecycle hooks: `scope.onDispose(d)` collects
17
+ teardown (run in reverse on `stop()`), `scope.onError(e)` sinks background
18
+ errors. Config is validated through any Standard Schema validator
19
+ (`Config`), with failures surfacing as `ConfigValidationError`.
20
+ - **Composition is function application.** `use(plugin, { mapping, option,
21
+ as })` feeds a plugin from the namespaces visible so far and exposes its
22
+ output on the final `ctx` under its name. `bind(...)` feeds it the same
23
+ way but keeps the output internal to the chain — visible to later
24
+ `mapping`s, absent from `ctx`. `mapping` is omitted when the plugin's
25
+ input keys already match visible namespaces (identity wiring); `option`
26
+ carries config, required exactly when the config type is non-void; `as`
27
+ renames the namespace.
28
+ - **Streams are the message-flow primitive.** `Stream<T>` is an
29
+ `AsyncIterable` with broadcast semantics — every consumer sees every item,
30
+ in order, at its own pace. Inputs push from callbacks through `channel()`
31
+ and emit a `shareStream` view; features transform with
32
+ `mapStream`/`filterStream`/`mergeStreams`; outputs consume command streams
33
+ with `pumpStream`. A feature handling two platforms merges their streams;
34
+ a command stream serving two platforms is filtered per output by
35
+ `address.platform` in the wiring `mapping`.
36
+ - **The envelope is free of platform semantics.** `Message` is `payload` +
37
+ `address` (+ `id`/`at`, minted by `message()`); `Command` is `address` +
38
+ `content`. `address` is opaque to the core and meaningful only to the
39
+ platform that produced it.
40
+ - **Platform-specific services are ordinary namespace values.** A REST
41
+ client, a webhook handler, a database connection — anything a plugin emits
42
+ lands on `ctx` (or stays internal via `bind`) with its type intact.
43
+ - **State is a plugin.** The core is stateless; a state plugin emits a
44
+ `StateBackend` as its namespace value, and a stateful feature declares the
45
+ backend in its input and builds a typed accessor namespaced to its own
46
+ name via `createStateAccessor(backend, name)`.
47
+ - **Activation order is definition order.** `start()` activates in
48
+ composition order — resolve mapping, validate config, `apply` — and
49
+ `stop()` disposes in reverse. Ordering mistakes are compile errors in the
50
+ mappings, not runtime states.
51
+ - **`expose(name)` seals a chain into a final engine.** The engine is the
52
+ chain as an artifact: named, runnable (`start`/`stop`/`ctx`), and wireable
53
+ into a supervisor kernel under its new name — but no longer composable
54
+ (`use`/`bind` are gone from the type and throw at runtime). Its type is
55
+ exactly `Engine<TIn, TVisible, TName>`: the chain's external input
56
+ requirement survives, while the `bind`-encapsulated internals and the
57
+ chain's own name are erased. This is how N instances of one bot stack nest
58
+ into a supervisor without name tags or leaked internals.
59
+
60
+ ## Usage
61
+
62
+ ```ts
63
+ import { consolePlatform, type ConsoleLine } from "@lambdot/console";
64
+ import type { Stream } from "@lambdot/core";
65
+ import { createKernel, definePlugin, mapStream } from "@lambdot/core";
66
+
67
+ const echo = definePlugin({
68
+ name: "echo",
69
+ apply(input: { "console/lines": Stream<ConsoleLine> }) {
70
+ return mapStream(input["console/lines"], (event) => ({
71
+ address: event.address,
72
+ content: `echo: ${event.payload}`,
73
+ }));
74
+ },
75
+ });
76
+
77
+ const cli = consolePlatform();
78
+
79
+ const kernel = createKernel()
80
+ .use(cli.lines) // exposes ctx["console/lines"]: Stream<ConsoleLine>
81
+ .use(echo) // identity wiring: the input keys already match
82
+ .bind(cli.printer, { mapping: (ctx) => ({ replies: ctx.echo }) });
83
+
84
+ await kernel.start();
85
+ process.on("SIGINT", () => void kernel.stop().then(() => process.exit(0)));
86
+ ```
87
+
88
+ `use(cli.lines)` exposes the line stream under `"console/lines"`. `use(echo)`
89
+ needs no `mapping`: its declared input `{ "console/lines": ... }` is already
90
+ satisfied by the visible ctx. The printer declares `{ replies: ... }`, which
91
+ no namespace provides — so the `mapping` is required, and its `ctx`
92
+ parameter is typed as exactly what's visible so far; referencing a
93
+ not-yet-composed namespace is a compile error. The printer is `bind`ed, so
94
+ `ctx["console/printer"]` does not typecheck.
95
+
96
+ ## API overview
97
+
98
+ Runtime values:
99
+
100
+ | Export | What it is |
101
+ | ------------------------------------ | ------------------------------------------------------------------------ |
102
+ | `createKernel(options?)` | Creates an empty composition; `options.onError` sinks background errors. |
103
+ | `definePlugin(spec)` | Authors a plugin from `{ name, Config?, apply }`. |
104
+ | `message(payload, address)` | Mints a `Message` envelope with a fresh `id`/`at`. |
105
+ | `channel()` | Push-side bridge from callbacks into the pull world. |
106
+ | `shareStream(stream)` | Multicasts a stream to any number of consumers. |
107
+ | `mapStream` | Per-item transform (async mapper allowed; items stay sequential). |
108
+ | `filterStream` | Per-item filter; the type-guard form narrows the item type. |
109
+ | `mergeStreams` | Interleaves several streams in arrival order. |
110
+ | `pumpStream` | Background sequential consumer; errors go to `onError`. |
111
+ | `createStateAccessor(backend, name)` | Typed, namespaced view over a `StateBackend`. |
112
+ | `ConfigValidationError` | Thrown when a plugin's `Config` schema rejects its config. |
113
+
114
+ Types, grouped by theme:
115
+
116
+ - **Messages** — `Message` (the inbound envelope: `payload`, `address`,
117
+ `id`, `at`), `Command` (the outbound pair: `address`, `content`),
118
+ `Address` (the `platform` routing tag).
119
+ - **Streams** — `Stream`, `Channel`.
120
+ - **Plugins** — `Plugin` (name, `Config`, `apply`, plus the composition
121
+ methods), `PluginSpec` (the author-facing half), `Scope` (`onDispose` /
122
+ `onError`), `Composite` (a composed chain — itself wireable), `Engine`
123
+ (a chain sealed by `expose`: final, named, internals erased), `AnyUnit`.
124
+ - **The composition types** — `InOf`, `OutOf`, `ConfigOf`, `NameOf`,
125
+ `WireArgs` (the `use`/`bind` options: `mapping` required when identity
126
+ wiring fails, `option` required when config is non-void, `as` to rename),
127
+ `StartArgs`, `Kernel` (a `Composite` seeded empty).
128
+ - **Config** — `StandardSchemaV1` (structural copy of the Standard Schema v1
129
+ interface; zod, valibot, arktype, … plug in with no runtime dependency).
130
+ - **State** — `StateBackend` (`get`/`set`/`delete` over namespace + key,
131
+ optional `ttlMs`), `StateAccessor`.
132
+ - **Lifecycle** — `Disposer`.
133
+ - **Kernel options** — `KernelOptions`.
134
+
135
+ ## Examples
136
+
137
+ The worked walkthroughs live in the repository's `examples/` directory:
138
+ `echo-bot` (the minimal bot above, plus compile-time composition tests in
139
+ `type-test.ts`), `counter-bot` (the pluggable-state walkthrough), and
140
+ `websocket-bot` (the transport-wiring walkthrough).
141
+
142
+ ## License
143
+
144
+ Dual-licensed under [Apache-2.0](../../../LICENSE-APACHE) and [MIT](../../../LICENSE-MIT).
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
- "name": "@lambdot/core",
3
- "version": "0.1.0",
4
- "type": "module",
5
- "exports": {
6
- ".": "./src/index.ts"
7
- },
8
- "publishConfig": {
9
- "access": "public"
10
- }
11
- }
2
+ "name": "@lambdot/core",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/Embers-of-the-Fire/lambdot",
7
+ "directory": "packages/core/core"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ }
16
+ }
package/src/effect.ts CHANGED
@@ -1,40 +1,9 @@
1
1
  /**
2
- * A function that undoes a registration or allocation. Collected by the
3
- * fiber that owns the plugin instance and run on unload/shutdown.
2
+ * A function that undoes a registration or allocation. Collected through
3
+ * `scope.onDispose` and run when the owning plugin unloads.
4
4
  */
5
5
  export type Disposer = () => void | Promise<void>;
6
6
 
7
- /**
8
- * Non-promise effect results. Kept separate from {@link Effect} so the
9
- * promise variant stays non-recursive and easy for TS to flatten.
10
- */
11
- export type EffectResult = void | Disposer | Iterable<Disposer> | AsyncIterable<Disposer>;
12
-
13
- /**
14
- * The result of a plugin's `apply`. Everything a plugin contributes is an
15
- * effect: disposers returned (or yielded) here are collected by the plugin's
16
- * fiber and run when the plugin unloads. There are no lifecycle hooks.
17
- */
18
- export type Effect = EffectResult | Promise<EffectResult>;
19
-
20
- /** Collect every disposer an effect produces into `sink`. */
21
- export async function collectEffect(
22
- effect: Effect,
23
- sink: (disposer: Disposer) => void,
24
- ): Promise<void> {
25
- const result = await effect;
26
- if (!result) return;
27
- if (typeof result === "function") {
28
- sink(result);
29
- return;
30
- }
31
- if (Symbol.asyncIterator in result) {
32
- for await (const disposer of result) sink(disposer);
33
- return;
34
- }
35
- for (const disposer of result) sink(disposer);
36
- }
37
-
38
7
  /** Run disposers in reverse registration order, isolating failures. */
39
8
  export async function runDisposers(
40
9
  disposers: readonly Disposer[],
package/src/index.ts CHANGED
@@ -1,42 +1,33 @@
1
- export type { Disposer, Effect, EffectResult } from "./effect.ts";
1
+ export type { Disposer } from "./effect.ts";
2
+ export type { Address, Command, Message } from "./message.ts";
3
+ export { message } from "./message.ts";
2
4
  export type {
3
- AnyBotEvent,
4
- BotEvent,
5
- EventDef,
6
- EventMap,
7
- IngressListener,
8
- Listener,
9
- NextFn,
10
- OnOptions,
11
- } from "./events.ts";
12
- export { INGRESS } from "./events.ts";
13
- export type {
14
- Address,
15
- ContentFor,
16
- ContextView,
17
- InputContext,
18
- OutputContract,
19
- OutputContractMap,
20
- } from "./context.ts";
21
- export type {
22
- AnyPlugin,
23
- CapsOf,
5
+ AnyUnit,
6
+ Composite,
24
7
  ConfigOf,
25
- EventsOf,
26
- FeaturePlugin,
27
- InputPlugin,
28
- InjectsOf,
29
- OutputPlugin,
30
- OutputsOf,
31
- PluginMeta,
32
- Spread,
33
- StateOf,
34
- Validate,
8
+ Engine,
9
+ InOf,
10
+ Kernel,
11
+ NameOf,
12
+ OutOf,
13
+ Plugin,
14
+ PluginSpec,
15
+ Scope,
16
+ StartArgs,
17
+ WireArgs,
35
18
  } from "./plugin.ts";
36
- export { definePlugin } from "./plugin.ts";
37
19
  export type { StandardSchemaV1 } from "./schema.ts";
38
20
  export { ConfigValidationError } from "./schema.ts";
39
- export type { StateAccessor, StateBackend, StateView } from "./state.ts";
40
- export type { FiberState } from "./fiber.ts";
21
+ export type { StateAccessor, StateBackend } from "./state.ts";
22
+ export { createStateAccessor } from "./state.ts";
23
+ export type { Channel, Stream } from "./stream.ts";
24
+ export {
25
+ channel,
26
+ filterStream,
27
+ mapStream,
28
+ mergeStreams,
29
+ pumpStream,
30
+ shareStream,
31
+ } from "./stream.ts";
41
32
  export type { KernelOptions } from "./kernel.ts";
42
- export { createKernel, Kernel } from "./kernel.ts";
33
+ export { createKernel, definePlugin } from "./kernel.ts";
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import type { Disposer, Scope } from "./index.ts";
5
+ import { createKernel, definePlugin } from "./index.ts";
6
+
7
+ function makeScope(disposers: Disposer[]): Scope {
8
+ return {
9
+ onDispose: (disposer) => {
10
+ disposers.push(disposer);
11
+ },
12
+ onError: () => {},
13
+ };
14
+ }
15
+
16
+ void test("engine.apply activates the chain and returns its visible context", async () => {
17
+ const greet = definePlugin<void, string>({
18
+ name: "greet",
19
+ apply: () => "hello",
20
+ });
21
+ const engine = createKernel().use(greet).expose("greeter");
22
+
23
+ const disposers: Disposer[] = [];
24
+ const output = await engine.apply(undefined, makeScope(disposers), undefined);
25
+ assert.deepEqual(output, { greet: "hello" });
26
+ assert.deepEqual(engine.ctx, { greet: "hello" });
27
+
28
+ // Applying the engine registered its teardown with the caller's scope.
29
+ for (const dispose of disposers.splice(0)) await dispose();
30
+ assert.deepEqual(engine.ctx, {});
31
+ });
32
+
33
+ void test("engine.apply feeds the external input through the chain", async () => {
34
+ const echo = definePlugin<{ value: string }, string>({
35
+ name: "echo",
36
+ apply: (input) => input.value.toUpperCase(),
37
+ });
38
+ const engine = createKernel()
39
+ .use(echo, { mapping: (ctx) => ctx as unknown as { value: string } })
40
+ .expose("upper");
41
+
42
+ const output = await engine.apply({ value: "hi" } as unknown as void, makeScope([]), undefined);
43
+ assert.deepEqual(output, { echo: "HI" });
44
+ });