@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 +16 -0
- package/README.md +144 -0
- package/package.json +15 -10
- package/src/effect.ts +2 -33
- package/src/index.ts +27 -36
- package/src/kernel.test.ts +44 -0
- package/src/kernel.ts +210 -241
- package/src/message.ts +37 -0
- package/src/plugin.ts +167 -185
- package/src/state.ts +4 -23
- package/src/stream.ts +281 -0
- package/src/context.ts +0 -120
- package/src/events.ts +0 -147
- package/src/fiber.ts +0 -41
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
export
|
|
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,
|
|
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
|
+
});
|