@six5536/smllm-wasm 0.0.0 → 0.4.1
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/README.md +137 -2
- package/dist/bundler.d.ts +8 -0
- package/dist/bundler.js +9 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/node/host.d.ts +12 -0
- package/dist/node/host.js +106 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +4 -0
- package/dist/node/storage.d.ts +9 -0
- package/dist/node/storage.js +75 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +26 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.js +4 -0
- package/dist/wrap.d.ts +64 -0
- package/dist/wrap.js +104 -0
- package/package.json +60 -5
- package/wasm/bundler/smllm_wasm.d.ts +70 -0
- package/wasm/bundler/smllm_wasm.js +9 -0
- package/wasm/bundler/smllm_wasm_bg.js +560 -0
- package/wasm/bundler/smllm_wasm_bg.wasm +0 -0
- package/wasm/bundler/smllm_wasm_bg.wasm.d.ts +22 -0
- package/wasm/web/smllm_wasm.d.ts +117 -0
- package/wasm/web/smllm_wasm.js +663 -0
- package/wasm/web/smllm_wasm_bg.wasm +0 -0
- package/wasm/web/smllm_wasm_bg.wasm.d.ts +22 -0
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# smllm-wasm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@six5536/smllm-wasm` is smllm's engine, `smllm-core`, compiled to WebAssembly. It is for a host
|
|
4
|
+
that runs state machines in its own process, such as a browser, a worker, a serverless function,
|
|
5
|
+
or Node. The large language model (LLM) can run anywhere. The API is typed: it takes objects and returns
|
|
6
|
+
objects, and the package includes TypeScript declarations.
|
|
7
|
+
|
|
8
|
+
This README helps you run the engine in your own host. It shows a first example, then describes
|
|
9
|
+
the host methods, storage, the Node helpers, and when to use the WebAssembly engine instead of the
|
|
10
|
+
CLI.
|
|
11
|
+
|
|
12
|
+
- [Host](#host)
|
|
13
|
+
- [Storage](#storage)
|
|
14
|
+
- [Run in Node](#run-in-node)
|
|
15
|
+
- [WebAssembly or CLI](#webassembly-or-cli)
|
|
16
|
+
- [Licence](#licence)
|
|
17
|
+
|
|
18
|
+
This example loads compiled machines, starts a session, and passes the model's tool calls to the
|
|
19
|
+
engine:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import init, { Engine, memoryStorage } from "@six5536/smllm-wasm"; // or from "@six5536/smllm-wasm/bundler"
|
|
23
|
+
|
|
24
|
+
await init(); // the bundler build needs no init
|
|
25
|
+
const compiled = await (await fetch("machines.json")).json(); // `smllm compile`
|
|
26
|
+
const engine = new Engine(
|
|
27
|
+
compiled,
|
|
28
|
+
{
|
|
29
|
+
// Every host method is optional; supports / check / run add host guard and
|
|
30
|
+
// action kinds (`command`), none by default.
|
|
31
|
+
},
|
|
32
|
+
memoryStorage(), // or your own Storage (see Storage); optional
|
|
33
|
+
);
|
|
34
|
+
const { session, text } = engine.bind("web");
|
|
35
|
+
// Give the model the same `smllm` tool the MCP server offers, and pass its
|
|
36
|
+
// calls through: a malformed call comes back as an answer, never a throw.
|
|
37
|
+
const tool = engine.tool(); // { name, description, inputSchema }
|
|
38
|
+
const reply = engine.callTool({ session, event: "enter", params: { stateMachine: "dev" } });
|
|
39
|
+
// When the model tries to stop (`stopHookActive`: it is already continuing
|
|
40
|
+
// because of an earlier block):
|
|
41
|
+
const stop = engine.stop(session, stopHookActive);
|
|
42
|
+
// stop.decision "block": hold the model, show it stop.text (the events list);
|
|
43
|
+
// "runaway": let it stop, show stop.text to the user; "allow": let it stop.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The `examples/wasm` folder of the [smllm repository](https://github.com/six5536/smllm) holds a
|
|
47
|
+
whole agent loop, in TypeScript.
|
|
48
|
+
|
|
49
|
+
## Host
|
|
50
|
+
|
|
51
|
+
A host can supply these methods. Each one is optional, and the engine uses its default when the
|
|
52
|
+
host leaves the method out:
|
|
53
|
+
|
|
54
|
+
- `supports(kind)` says which guard and action kinds the host runs. By default, it supports none.
|
|
55
|
+
- `check(kind, params, env, cwd)` checks a guard of a supported kind.
|
|
56
|
+
- `run(kind, params, env, cwd)` runs an action of a supported kind. It returns nothing on
|
|
57
|
+
success, and the detail of the failure otherwise. A command's `params.cwd` is relative to the
|
|
58
|
+
compiled config file.
|
|
59
|
+
- `read(file)` reads a prompt file. `smllm compile` inlines prompt files, so a host rarely needs
|
|
60
|
+
this method.
|
|
61
|
+
- `isMatch(pattern, value)` matches a pattern. By default, it uses `new RegExp`.
|
|
62
|
+
- `now()` returns the time. By default, it uses `Date.now`.
|
|
63
|
+
- `random()` returns a random number. By default, it uses `crypto.getRandomValues`.
|
|
64
|
+
- `history(machine, id, entry)` and `put(kind, key, record)` receive writes. By default, the
|
|
65
|
+
engine drops them.
|
|
66
|
+
|
|
67
|
+
A host method that throws is a host failure: a failed guard or action, or a rejected pattern. The
|
|
68
|
+
reply text shows the failure, and the engine stays usable.
|
|
69
|
+
|
|
70
|
+
## Storage
|
|
71
|
+
|
|
72
|
+
The engine works in memory and writes through to storage. It passes each session, binding, and
|
|
73
|
+
instance to `put` when it saves them, and each history entry to `history`. Persistence therefore
|
|
74
|
+
costs only what changed, never a snapshot for each call.
|
|
75
|
+
|
|
76
|
+
A `Storage` is `{ load(), put(kind, key, record), history(machine, id, entry) }`. The engine
|
|
77
|
+
takes a `Storage` as the constructor's third argument, loads it once, and then hands it every
|
|
78
|
+
write.
|
|
79
|
+
`memoryStorage()` keeps the state in memory. In a browser, you can write a `Storage` over
|
|
80
|
+
IndexedDB, or one that sends the records to a server. `exportState()` and `importState(state)`
|
|
81
|
+
move the whole state at once, for backups and moves.
|
|
82
|
+
|
|
83
|
+
## Run in Node
|
|
84
|
+
|
|
85
|
+
`@six5536/smllm-wasm/node` provides a host and a storage for Node:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
import { nodeFileStorage, nodeHost } from "@six5536/smllm-wasm/node";
|
|
89
|
+
|
|
90
|
+
const engine = new Engine(
|
|
91
|
+
compiled,
|
|
92
|
+
nodeHost({ configDir: "/path/to/the/compiled/config/folder" }),
|
|
93
|
+
nodeFileStorage("/path/to/state"),
|
|
94
|
+
);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`nodeHost` runs `command` guards and actions as the smllm CLI does:
|
|
98
|
+
|
|
99
|
+
- It runs a string `run` through the shell, and a list without one.
|
|
100
|
+
- It sets the `SMLLM_*` environment variables.
|
|
101
|
+
- It applies a timeout, 60 seconds by default, and the timeout also kills the processes that the
|
|
102
|
+
command started.
|
|
103
|
+
- On a failure, it reports the last 400 characters of output.
|
|
104
|
+
|
|
105
|
+
`nodeFileStorage` keeps one file for each record, written atomically, and keeps history as JSON Lines (JSONL).
|
|
106
|
+
Only one process can own a storage folder.
|
|
107
|
+
|
|
108
|
+
## WebAssembly or CLI
|
|
109
|
+
|
|
110
|
+
The following table gives the median time for each call. The project's benchmark measured the
|
|
111
|
+
times in a Linux container with a local disk. Each column heading gives the size of the store as the
|
|
112
|
+
number of paused instances and the number of completed instances.
|
|
113
|
+
|
|
114
|
+
| Path | Call | 0 / 0 | 100 / 1000 | 1000 / 1000 |
|
|
115
|
+
| ---- | ---- | ---: | ---: | ---: |
|
|
116
|
+
| wasm, in-process | `fire` enter / pause | 0.06 ms | 0.02 / 0.03 ms | 0.02 / 0.03 ms |
|
|
117
|
+
| wasm, in-process | view, stop | 0.02 ms | 0.006 ms | 0.006 ms |
|
|
118
|
+
| CLI, `smllm mcp` (one process, over stdio) | tool call | 0.2–0.4 ms | 0.1–0.6 ms | 0.1–2.3 ms |
|
|
119
|
+
| CLI, a process per call | `smllm fire`, `statusline` | 1.4–1.7 ms | 1.2–1.8 ms | 1.3–4.4 ms |
|
|
120
|
+
|
|
121
|
+
Use the WebAssembly engine in these cases:
|
|
122
|
+
|
|
123
|
+
- In a browser, a worker, or a serverless function, where there is no binary and no shell.
|
|
124
|
+
- For a model that answers in milliseconds.
|
|
125
|
+
- For guards and actions written in JavaScript.
|
|
126
|
+
- For storage of your own.
|
|
127
|
+
|
|
128
|
+
Use the CLI, from the `@six5536/smllm` npm package, when a Node app needs the CLI's behaviour as
|
|
129
|
+
it is. For tool calls, run `smllm mcp`. The CLI shares files between processes with locking, and
|
|
130
|
+
provides the status line, `smllm instance list`, and the Claude Code hooks.
|
|
131
|
+
|
|
132
|
+
The string API underneath, which takes and returns JSON text, is in `@six5536/smllm-wasm/raw` and
|
|
133
|
+
`@six5536/smllm-wasm/raw/bundler`.
|
|
134
|
+
|
|
135
|
+
## Licence
|
|
136
|
+
|
|
137
|
+
smllm-wasm is licensed under the MIT licence. Its source is in the
|
|
138
|
+
[smllm repository](https://github.com/six5536/smllm).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Engine as Raw } from "../wasm/bundler/smllm_wasm.js";
|
|
2
|
+
import { Engine as Base } from "./wrap.js";
|
|
3
|
+
export type * from "./types.js";
|
|
4
|
+
export { memoryStorage } from "./storage.js";
|
|
5
|
+
/** smllm's engine over compiled machines (`smllm compile` output). */
|
|
6
|
+
export declare class Engine extends Base {
|
|
7
|
+
protected static Raw: typeof Raw;
|
|
8
|
+
}
|
package/dist/bundler.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// smllm-wasm for bundlers: the wasm loads on import. The string API is
|
|
2
|
+
// `smllm-wasm/raw/bundler`.
|
|
3
|
+
import { Engine as Raw } from "../wasm/bundler/smllm_wasm.js";
|
|
4
|
+
import { Engine as Base } from "./wrap.js";
|
|
5
|
+
export { memoryStorage } from "./storage.js";
|
|
6
|
+
/** smllm's engine over compiled machines (`smllm compile` output). */
|
|
7
|
+
export class Engine extends Base {
|
|
8
|
+
static Raw = Raw;
|
|
9
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import init, { Engine as Raw, initSync } from "../wasm/web/smllm_wasm.js";
|
|
2
|
+
import { Engine as Base } from "./wrap.js";
|
|
3
|
+
export default init;
|
|
4
|
+
export { initSync };
|
|
5
|
+
export type * from "./types.js";
|
|
6
|
+
export { memoryStorage } from "./storage.js";
|
|
7
|
+
/** smllm's engine over compiled machines (`smllm compile` output). */
|
|
8
|
+
export declare class Engine extends Base {
|
|
9
|
+
protected static Raw: typeof Raw;
|
|
10
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// smllm-wasm for browsers and Node (the web build): call `init()` (or
|
|
2
|
+
// `initSync`) once, then use `Engine`. The string API is `smllm-wasm/raw`.
|
|
3
|
+
import init, { Engine as Raw, initSync } from "../wasm/web/smllm_wasm.js";
|
|
4
|
+
import { Engine as Base } from "./wrap.js";
|
|
5
|
+
export default init;
|
|
6
|
+
export { initSync };
|
|
7
|
+
export { memoryStorage } from "./storage.js";
|
|
8
|
+
/** smllm's engine over compiled machines (`smllm compile` output). */
|
|
9
|
+
export class Engine extends Base {
|
|
10
|
+
static Raw = Raw;
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Host } from "../types.js";
|
|
2
|
+
/** `timeoutSecs` default (DEC-5), as the CLI's. */
|
|
3
|
+
export declare const DEFAULT_TIMEOUT_SECS = 60;
|
|
4
|
+
/**
|
|
5
|
+
* A host that runs `command` guards and actions. `configDir` is the folder
|
|
6
|
+
* of the compiled config: a command's `cwd` is relative to it (D8-22).
|
|
7
|
+
* Spread it into your host: `new Engine(compiled, { ...nodeHost({ configDir }), ... })`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function nodeHost(options: {
|
|
10
|
+
configDir: string;
|
|
11
|
+
timeoutSecs?: number;
|
|
12
|
+
}): Host;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The Node command host (HOST-15): `command` guards
|
|
2
|
+
// and actions run as the CLI runs them (DEC-4..DEC-7). Host methods are
|
|
3
|
+
// synchronous, so commands run with `spawnSync`. On unix the command is
|
|
4
|
+
// started detached, as the leader of its own process group, and a timeout
|
|
5
|
+
// kills that whole group, so what the command started dies with it; its
|
|
6
|
+
// output goes to a file, not a pipe, so a process it leaves running cannot
|
|
7
|
+
// hold the call open. On Windows the command's own process is killed, as the
|
|
8
|
+
// CLI does.
|
|
9
|
+
// @zen-component: HOST-JsPackage
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
/** `timeoutSecs` default (DEC-5), as the CLI's. */
|
|
15
|
+
export const DEFAULT_TIMEOUT_SECS = 60;
|
|
16
|
+
const TAIL_CHARS = 400;
|
|
17
|
+
/** The last `TAIL_CHARS` characters of `text`, on one line (as the CLI's). */
|
|
18
|
+
function tail(text) {
|
|
19
|
+
const chars = [...text.trim()];
|
|
20
|
+
const cut = chars.length > TAIL_CHARS;
|
|
21
|
+
const s = chars.slice(-TAIL_CHARS).join("").split(/\s+/).filter(Boolean).join(" ");
|
|
22
|
+
return cut ? `…${s}` : s;
|
|
23
|
+
}
|
|
24
|
+
/** A command's failure text, `""` when it passed. */
|
|
25
|
+
function runCommand(params, env, sessionCwd, configDir, timeoutSecs) {
|
|
26
|
+
const run = params.run;
|
|
27
|
+
let argv;
|
|
28
|
+
if (typeof run === "string") {
|
|
29
|
+
argv = process.platform === "win32" ? ["cmd", "/C", run] : ["sh", "-c", run];
|
|
30
|
+
}
|
|
31
|
+
else if (Array.isArray(run) && run.length > 0 && run.every((a) => typeof a === "string")) {
|
|
32
|
+
argv = run;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
return "no run param";
|
|
36
|
+
}
|
|
37
|
+
// `params.cwd` is relative to the compiled config (D8-22); else the
|
|
38
|
+
// session's working directory, when there is one.
|
|
39
|
+
const cwd = typeof params.cwd === "string" ? resolve(configDir, params.cwd) : sessionCwd || undefined;
|
|
40
|
+
const secs = typeof params.timeoutSecs === "number" ? params.timeoutSecs : timeoutSecs;
|
|
41
|
+
const unix = process.platform !== "win32";
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "smllm-command-"));
|
|
43
|
+
const out = join(dir, "out");
|
|
44
|
+
const fd = openSync(out, "w");
|
|
45
|
+
try {
|
|
46
|
+
// `detached` is honoured by spawnSync as by spawn, though its typings
|
|
47
|
+
// omit it; the test that a timed-out grandchild dies guards this.
|
|
48
|
+
const options = {
|
|
49
|
+
cwd,
|
|
50
|
+
env: { ...process.env, ...env },
|
|
51
|
+
stdio: ["ignore", fd, fd],
|
|
52
|
+
timeout: secs * 1000,
|
|
53
|
+
killSignal: "SIGKILL",
|
|
54
|
+
// Its own session and process group (spawnSync honours it as spawn
|
|
55
|
+
// does), so a timeout can kill what it started too.
|
|
56
|
+
detached: unix,
|
|
57
|
+
windowsVerbatimArguments: argv[0] === "cmd",
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
};
|
|
60
|
+
const r = spawnSync(argv[0], argv.slice(1), options);
|
|
61
|
+
const timedOut = r.error?.code === "ETIMEDOUT";
|
|
62
|
+
if (timedOut) {
|
|
63
|
+
if (unix && r.pid) {
|
|
64
|
+
// The leader is gone; the rest of its group goes too. A group id is
|
|
65
|
+
// not reused while members remain.
|
|
66
|
+
try {
|
|
67
|
+
process.kill(-r.pid, "SIGKILL");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Nothing left in the group.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return `timed out after ${secs}s`;
|
|
74
|
+
}
|
|
75
|
+
if (r.error) {
|
|
76
|
+
return `could not start: ${r.error.message}`;
|
|
77
|
+
}
|
|
78
|
+
return failure(r.status, r.status === 0 ? "" : readFileSync(out, "utf8"));
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function failure(status, output) {
|
|
86
|
+
if (status === 0) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
const code = status === null ? "killed by a signal" : `exited ${status}`;
|
|
90
|
+
const t = tail(output);
|
|
91
|
+
return t ? `${code}: ${t}` : code;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A host that runs `command` guards and actions. `configDir` is the folder
|
|
95
|
+
* of the compiled config: a command's `cwd` is relative to it (D8-22).
|
|
96
|
+
* Spread it into your host: `new Engine(compiled, { ...nodeHost({ configDir }), ... })`.
|
|
97
|
+
*/
|
|
98
|
+
export function nodeHost(options) {
|
|
99
|
+
const { configDir, timeoutSecs = DEFAULT_TIMEOUT_SECS } = options;
|
|
100
|
+
const exec = (kind, params, env, cwd) => kind === "command" ? runCommand(params, env, cwd, configDir, timeoutSecs) : `${kind} is not supported`;
|
|
101
|
+
return {
|
|
102
|
+
supports: (kind) => kind === "command",
|
|
103
|
+
check: (kind, params, env, cwd) => exec(kind, params, env, cwd) === "",
|
|
104
|
+
run: (kind, params, env, cwd) => exec(kind, params, env, cwd),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Storage } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A file name for `key`: `[a-z0-9-]` kept, every other byte %-escaped (upper
|
|
4
|
+
* case too, so a case-insensitive file system cannot merge two keys), cut
|
|
5
|
+
* with an FNV-1a hash when long.
|
|
6
|
+
*/
|
|
7
|
+
export declare function fileName(key: string): string;
|
|
8
|
+
/** Storage in `dir`: `sessions/`, `bindings/`, `instances/` and `history/`. */
|
|
9
|
+
export declare function nodeFileStorage(dir: string): Storage;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Node file storage (HOST-14): a file per record, written
|
|
2
|
+
// atomically (temp file, then rename), and history as JSONL, one line per
|
|
3
|
+
// entry in the CLI's format. One process owns a folder: several processes
|
|
4
|
+
// sharing state should use the CLI, whose store locks.
|
|
5
|
+
// @zen-component: HOST-JsPackage
|
|
6
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
const SHELF = {
|
|
9
|
+
session: "sessions",
|
|
10
|
+
binding: "bindings",
|
|
11
|
+
instance: "instances",
|
|
12
|
+
};
|
|
13
|
+
/** Longest file name before `.json`; longer ones are cut and end in a hash. */
|
|
14
|
+
const MAX_NAME = 120;
|
|
15
|
+
/**
|
|
16
|
+
* A file name for `key`: `[a-z0-9-]` kept, every other byte %-escaped (upper
|
|
17
|
+
* case too, so a case-insensitive file system cannot merge two keys), cut
|
|
18
|
+
* with an FNV-1a hash when long.
|
|
19
|
+
*/
|
|
20
|
+
export function fileName(key) {
|
|
21
|
+
let name = "";
|
|
22
|
+
for (const b of new TextEncoder().encode(key)) {
|
|
23
|
+
const c = String.fromCharCode(b);
|
|
24
|
+
name += /[a-z0-9-]/.test(c) ? c : `%${b.toString(16).padStart(2, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
if (name.length > MAX_NAME) {
|
|
27
|
+
let h = 0xcbf29ce484222325n;
|
|
28
|
+
for (const b of new TextEncoder().encode(key)) {
|
|
29
|
+
h = ((h ^ BigInt(b)) * 0x100000001b3n) & 0xffffffffffffffffn;
|
|
30
|
+
}
|
|
31
|
+
name = `${name.slice(0, MAX_NAME - 17)}~${h.toString(16).padStart(16, "0")}`;
|
|
32
|
+
}
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
let written = 0;
|
|
36
|
+
/** Write `text` to `path` whole or not at all. */
|
|
37
|
+
function writeAtomic(path, text) {
|
|
38
|
+
const temp = `${path}.${process.pid}.${written++}.tmp`;
|
|
39
|
+
writeFileSync(temp, text);
|
|
40
|
+
renameSync(temp, path);
|
|
41
|
+
}
|
|
42
|
+
/** Storage in `dir`: `sessions/`, `bindings/`, `instances/` and `history/`. */
|
|
43
|
+
export function nodeFileStorage(dir) {
|
|
44
|
+
return {
|
|
45
|
+
load() {
|
|
46
|
+
const state = { sessions: {}, bindings: {}, instances: {} };
|
|
47
|
+
let any = false;
|
|
48
|
+
for (const shelf of Object.values(SHELF)) {
|
|
49
|
+
let names;
|
|
50
|
+
try {
|
|
51
|
+
names = readdirSync(join(dir, shelf)).filter((n) => n.endsWith(".json"));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const n of names) {
|
|
57
|
+
const { key, record } = JSON.parse(readFileSync(join(dir, shelf, n), "utf8"));
|
|
58
|
+
state[shelf][key] = record;
|
|
59
|
+
any = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return any ? state : undefined;
|
|
63
|
+
},
|
|
64
|
+
put(kind, key, record) {
|
|
65
|
+
const shelf = join(dir, SHELF[kind]);
|
|
66
|
+
mkdirSync(shelf, { recursive: true });
|
|
67
|
+
writeAtomic(join(shelf, `${fileName(key)}.json`), JSON.stringify({ key, record }));
|
|
68
|
+
},
|
|
69
|
+
history(machine, id, entry) {
|
|
70
|
+
const folder = join(dir, "history", fileName(machine));
|
|
71
|
+
mkdirSync(folder, { recursive: true });
|
|
72
|
+
appendFileSync(join(folder, `${fileName(id)}.jsonl`), `${JSON.stringify(entry)}\n`);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// In-memory storage (HOST-14): what `nodeFileStorage` does with files,
|
|
2
|
+
// without them; for tests, examples and hosts that persist elsewhere.
|
|
3
|
+
// @zen-component: HOST-JsPackage
|
|
4
|
+
/** A storage that keeps everything in memory. `history` holds the log by `machine/id`. */
|
|
5
|
+
export function memoryStorage() {
|
|
6
|
+
const state = { sessions: {}, bindings: {}, instances: {} };
|
|
7
|
+
const log = new Map();
|
|
8
|
+
let empty = true;
|
|
9
|
+
const shelf = {
|
|
10
|
+
session: state.sessions,
|
|
11
|
+
binding: state.bindings,
|
|
12
|
+
instance: state.instances,
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
load: () => (empty ? undefined : structuredClone(state)),
|
|
16
|
+
put(kind, key, record) {
|
|
17
|
+
shelf[kind][key] = record;
|
|
18
|
+
empty = false;
|
|
19
|
+
},
|
|
20
|
+
history(machine, id, entry) {
|
|
21
|
+
const key = `${machine}/${id}`;
|
|
22
|
+
log.set(key, [...(log.get(key) ?? []), entry]);
|
|
23
|
+
},
|
|
24
|
+
log,
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Where a session is after a call; all `null` in idle. */
|
|
2
|
+
export interface Location {
|
|
3
|
+
machine: string | null;
|
|
4
|
+
state: string | null;
|
|
5
|
+
instance: string | null;
|
|
6
|
+
ref: string | null;
|
|
7
|
+
}
|
|
8
|
+
/** The answer to a call: the `<smllm>` text for the agent, and where the session is. */
|
|
9
|
+
export interface Reply {
|
|
10
|
+
/** `false` when the event was rejected or the instance moved. */
|
|
11
|
+
ok: boolean;
|
|
12
|
+
session: string;
|
|
13
|
+
location: Location;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
/** The stop hook's decision (TURN-4..6). */
|
|
17
|
+
export type StopDecision = {
|
|
18
|
+
decision: "allow";
|
|
19
|
+
}
|
|
20
|
+
/** `block`: hold the agent and show it `text`; `runaway`: let it stop, show `text` to the user. */
|
|
21
|
+
| {
|
|
22
|
+
decision: "block" | "runaway";
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
25
|
+
export interface InstanceStatus {
|
|
26
|
+
machine: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
id: string;
|
|
29
|
+
ref: string | null;
|
|
30
|
+
label: string;
|
|
31
|
+
status: "active" | "interrupted" | "paused" | "completed";
|
|
32
|
+
}
|
|
33
|
+
/** Where a session is, as data (the `smllm statusline --json` object). */
|
|
34
|
+
export interface SessionStatus {
|
|
35
|
+
session: string;
|
|
36
|
+
idle: boolean;
|
|
37
|
+
machine: string | null;
|
|
38
|
+
state: string | null;
|
|
39
|
+
visit: number | null;
|
|
40
|
+
yielded: boolean;
|
|
41
|
+
instance: InstanceStatus | null;
|
|
42
|
+
interrupted: InstanceStatus | null;
|
|
43
|
+
paused: number;
|
|
44
|
+
}
|
|
45
|
+
/** One history entry, handed to `Host.history` as it happens. */
|
|
46
|
+
export interface HistoryEntry {
|
|
47
|
+
/** Unix ms. */
|
|
48
|
+
at: number;
|
|
49
|
+
session: string;
|
|
50
|
+
event: string;
|
|
51
|
+
from: string | null;
|
|
52
|
+
to: string | null;
|
|
53
|
+
params: Record<string, string>;
|
|
54
|
+
trace: string[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What the engine needs from its host. Every method is optional:
|
|
58
|
+
* - `supports`, `check`, `run`: host guard/action kinds (`command`); none by default.
|
|
59
|
+
* A command's `params.cwd` is relative to the compiled config file (`nodeHost`'s `configDir`).
|
|
60
|
+
* - `read`: prompt files (`smllm compile` inlines them, so rarely needed).
|
|
61
|
+
* - `isMatch`: `new RegExp(pattern).test(value)` by default.
|
|
62
|
+
* - `now`, `random`: `Date.now()` and `crypto.getRandomValues` by default.
|
|
63
|
+
* - `history`: each history entry as it happens; dropped by default.
|
|
64
|
+
* - `put`: each session, binding and instance as it is saved (write-through storage); dropped by default.
|
|
65
|
+
* A method that throws is a host failure the engine reports, never a broken engine.
|
|
66
|
+
*/
|
|
67
|
+
export interface Host {
|
|
68
|
+
supports?(kind: string): boolean;
|
|
69
|
+
check?(kind: string, params: Record<string, unknown>, env: Record<string, string>, cwd: string): boolean;
|
|
70
|
+
/** Nothing (or `""`) on success, else the failure detail. */
|
|
71
|
+
run?(kind: string, params: Record<string, unknown>, env: Record<string, string>, cwd: string): string | void;
|
|
72
|
+
read?(file: string): string | undefined;
|
|
73
|
+
isMatch?(pattern: string, value: string): boolean;
|
|
74
|
+
now?(): number;
|
|
75
|
+
random?(): number;
|
|
76
|
+
history?(machine: string, id: string, entry: HistoryEntry): void;
|
|
77
|
+
put?(kind: RecordKind, key: string, record: unknown): void;
|
|
78
|
+
}
|
|
79
|
+
/** What `put` hands on: a session (key: its key), a binding (`harness/hostSession`, record: the session key) or an instance (`machine/id`). */
|
|
80
|
+
export type RecordKind = "session" | "binding" | "instance";
|
|
81
|
+
/**
|
|
82
|
+
* Where an engine's state lives (HOST-14): `load` once, into `importState`;
|
|
83
|
+
* then each saved record through `put` and each history entry through
|
|
84
|
+
* `history`, as they happen, so persisting costs what changed.
|
|
85
|
+
*/
|
|
86
|
+
export interface Storage {
|
|
87
|
+
load(): State | undefined;
|
|
88
|
+
put(kind: RecordKind, key: string, record: unknown): void;
|
|
89
|
+
history(machine: string, id: string, entry: HistoryEntry): void;
|
|
90
|
+
}
|
|
91
|
+
/** The agent-facing `smllm` tool: the same definition the MCP server offers. */
|
|
92
|
+
export interface Tool {
|
|
93
|
+
name: string;
|
|
94
|
+
description: string;
|
|
95
|
+
/** JSON Schema of `ToolArgs`. */
|
|
96
|
+
inputSchema: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
/** The agent's tool arguments. */
|
|
99
|
+
export interface ToolArgs {
|
|
100
|
+
session?: string;
|
|
101
|
+
event?: string;
|
|
102
|
+
params?: Record<string, string>;
|
|
103
|
+
}
|
|
104
|
+
/** Sessions, bindings and instances by key, as `exportState` returns them and `importState` takes them. */
|
|
105
|
+
export interface State {
|
|
106
|
+
sessions: Record<string, unknown>;
|
|
107
|
+
bindings: Record<string, string>;
|
|
108
|
+
instances: Record<string, unknown>;
|
|
109
|
+
}
|
package/dist/types.js
ADDED
package/dist/wrap.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Host, RecordKind, Reply, SessionStatus, State, Storage, StopDecision, Tool, ToolArgs } from "./types.js";
|
|
2
|
+
/** The host the wasm calls: strings in, strings out, every method there. */
|
|
3
|
+
export interface RawHost {
|
|
4
|
+
supports(kind: string): boolean;
|
|
5
|
+
check(kind: string, params: string, env: string, cwd: string): boolean;
|
|
6
|
+
run(kind: string, params: string, env: string, cwd: string): string;
|
|
7
|
+
read(file: string): string | undefined;
|
|
8
|
+
isMatch(pattern: string, value: string): boolean;
|
|
9
|
+
now(): number;
|
|
10
|
+
random(): number;
|
|
11
|
+
history(machine: string, id: string, entry: string): void;
|
|
12
|
+
put(kind: RecordKind, key: string, record: string): void;
|
|
13
|
+
}
|
|
14
|
+
/** A build's wasm-bindgen `Engine`: JSON text in and out. */
|
|
15
|
+
export interface RawEngine {
|
|
16
|
+
tool(): string;
|
|
17
|
+
callTool(args: string): string;
|
|
18
|
+
unsupported(): string;
|
|
19
|
+
bind(harness: string, hostSession: string | null | undefined, cwd: string): string;
|
|
20
|
+
view(key: string): string;
|
|
21
|
+
events(key: string): string;
|
|
22
|
+
status(key: string): string;
|
|
23
|
+
fire(key: string | null | undefined, event: string, params: string): string;
|
|
24
|
+
stop(key: string, stopHookActive: boolean): string;
|
|
25
|
+
promptSubmitted(key: string): void;
|
|
26
|
+
exportState(): string;
|
|
27
|
+
importState(state: string): void;
|
|
28
|
+
free(): void;
|
|
29
|
+
}
|
|
30
|
+
/** A build's wasm-bindgen `Engine` class. */
|
|
31
|
+
export type RawClass = new (compiled: string, host: RawHost) => RawEngine;
|
|
32
|
+
/** smllm's engine over compiled machines (`smllm compile` output). */
|
|
33
|
+
export declare class Engine {
|
|
34
|
+
#private;
|
|
35
|
+
/** The build's raw `Engine`: set by the entry module's subclass. */
|
|
36
|
+
protected static Raw: RawClass;
|
|
37
|
+
/**
|
|
38
|
+
* `storage`, when given, is loaded now and then receives every saved
|
|
39
|
+
* record and history entry (its `put` and `history` go before `host`'s).
|
|
40
|
+
*/
|
|
41
|
+
constructor(compiled: string | object, host?: Host, storage?: Storage);
|
|
42
|
+
/** The agent-facing tool, to hand to an LLM's tool list (HOST-13). */
|
|
43
|
+
tool(): Tool;
|
|
44
|
+
/** Answer a tool call as the MCP server does; a malformed call is `ok: false`, never a throw. */
|
|
45
|
+
callTool(args?: ToolArgs): Reply;
|
|
46
|
+
/** Guard and action kinds the machines use that this host cannot run. */
|
|
47
|
+
unsupported(): string[];
|
|
48
|
+
/** Bind a harness session (reused when `hostSession` is already bound). */
|
|
49
|
+
bind(harness: string, hostSession?: string, cwd?: string): Reply;
|
|
50
|
+
/** Where the session is; changes nothing. */
|
|
51
|
+
view(key: string): Reply;
|
|
52
|
+
/** The events list of the session's state. */
|
|
53
|
+
events(key: string): Reply;
|
|
54
|
+
status(key: string): SessionStatus;
|
|
55
|
+
/** Fire an event; with no key, only `enter` (it starts a session). */
|
|
56
|
+
fire(key: string | undefined, event: string, params?: Record<string, string>): Reply;
|
|
57
|
+
stop(key: string, stopHookActive?: boolean): StopDecision;
|
|
58
|
+
promptSubmitted(key: string): void;
|
|
59
|
+
exportState(): State;
|
|
60
|
+
importState(state: State | string): void;
|
|
61
|
+
/** Free the wasm memory now, rather than when collected. */
|
|
62
|
+
free(): void;
|
|
63
|
+
[Symbol.dispose](): void;
|
|
64
|
+
}
|