@vincemakes/kiso-server 0.41.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @vincemakes/kiso-server
2
+
3
+ The hosted-session service: many durable kiso sessions in one long-lived
4
+ process, each with observers, one run at a time. It is the layer a product
5
+ backend puts between its HTTP (or socket, or stdio) handlers and the
6
+ runtime — the layer two products wrote independently before this package
7
+ existed.
8
+
9
+ It is not a transport, and it is not a second agent. Every session here is
10
+ `runtime.createAgent(...).session({ id })`; every run is that session's
11
+ `run()` / `resume()`. The product supplies the agent factory; the service
12
+ never learns what the agent is for.
13
+
14
+ ```ts
15
+ import { createSessionService } from "@vincemakes/kiso-server";
16
+ import { createAgent, SessionStore } from "@vincemakes/kiso-runtime";
17
+
18
+ const store = new SessionStore("./sessions");
19
+ const service = createSessionService({
20
+ store,
21
+ open: async (sessionId) => createAgent({ /* the product's definition */ store, model, tools, extensions }),
22
+ });
23
+
24
+ const { runId, done } = await service.run("s1", "hello"); // InFlightError | OpenRunError | DrainingError
25
+ const off = await service.subscribe("s1", lastSeqSeen, (ev) => send(ev)); // replay, then live, exactly once
26
+ await service.approve("s1", decisionId, true); // → { needsResume }
27
+ await service.abort("s1"); // → idle | parked (who is waited for) | stopped
28
+ await service.drain(20_000); // → { waitedFor, parked, interrupted, timedOut }
29
+ await service.close(5_000); // abort what is live, wait up to the grace
30
+ ```
31
+
32
+ ## What it decides, and what it leaves to the host
33
+
34
+ - **One run per session.** A second `run()` while one is live refuses
35
+ with `InFlightError`.
36
+ - **An open run in the log** (a previous process died inside it) refuses
37
+ with `OpenRunError`; `run(input, { resumeFirst: true })` resumes it to its
38
+ terminal first. Silent resume is the one option not offered.
39
+ - **Abort while parked.** A run waiting for a person — a kernel approval,
40
+ or whatever `hooks.parkedBy` names (the ask extension's questions) —
41
+ is parked, not running. `abort()` returns `{ kind: "parked", ... }` and
42
+ does nothing; `{ force: true }` aborts anyway. "stopped" carries a
43
+ `settled` promise: the kernel does not kill a tool, so a tool that
44
+ ignores the abort keeps its run open until it returns.
45
+ - **After the last uncertain verdict** the service does not resume;
46
+ `resolveUncertain` returns `{ remaining }` and the host resumes at zero.
47
+ - **Executing tools** are counted from `tool_execution_started` and the
48
+ three ending events (succeeded, failed, resolved) — `EXECUTION_ENDED`,
49
+ stated once.
50
+ - **Product frames on the stream** (billing, estimates, couriers) are the
51
+ transport's business; the service delivers the durable events only.
52
+
53
+ ## What a host still writes
54
+
55
+ The transport (routes, SSE framing, `Last-Event-ID`, keepalive, status
56
+ codes), the agent factory, and any per-session product state it feeds the
57
+ factory. The service is the part that was the same in every host.
58
+
59
+ ## The HTTP + SSE transport — `@vincemakes/kiso-server/http`
60
+
61
+ `createHttpHandler(service, { authorize, augment?, prepareInput?, projection?, keepaliveMs? })`
62
+ returns a `handle(req, res)` a host mounts in front of its own routes; it
63
+ answers the agent routes under `prefix` (default `/v1/sessions`) and
64
+ returns false for everything else. Status codes and framing are decided
65
+ here once: `id: <seq>` / `event: <type>` / `data: <WireEvent>`, an `: open`
66
+ preamble, `Last-Event-ID` and `?after`, a keepalive; `in_flight` and
67
+ `open_run` are 409, `draining` 503, `forbidden` 403. Wire events are the
68
+ projection in `@vincemakes/kiso-protocol` — never the durable event.
69
+ `authorize` is required: there is no default that allows.
70
+
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The service's typed refusals. A transport maps each to its own status
3
+ * (409 / 409 / 503 in the two products that wrote these by hand); the
4
+ * service itself knows no status code.
5
+ */
6
+ /** A run is already in flight for this session — one run per session. */
7
+ export declare class InFlightError extends Error {
8
+ readonly sessionId: string;
9
+ readonly runId: string;
10
+ constructor(sessionId: string, runId: string);
11
+ }
12
+ /** The log holds a run that never reached its terminal — a previous
13
+ * process died inside it. It must be resumed (recovery drives ONE run to
14
+ * its terminal) before a new turn starts; `run(..., { resumeFirst: true })`
15
+ * does that in one call. The one thing the service never does is resume
16
+ * silently. */
17
+ export declare class OpenRunError extends Error {
18
+ readonly sessionId: string;
19
+ readonly runId: string;
20
+ constructor(sessionId: string, runId: string);
21
+ }
22
+ /** drain() was called: no new run or resume starts in this process. */
23
+ export declare class DrainingError extends Error {
24
+ constructor();
25
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The service's typed refusals. A transport maps each to its own status
3
+ * (409 / 409 / 503 in the two products that wrote these by hand); the
4
+ * service itself knows no status code.
5
+ */
6
+ /** A run is already in flight for this session — one run per session. */
7
+ export class InFlightError extends Error {
8
+ sessionId;
9
+ runId;
10
+ constructor(sessionId, runId) {
11
+ super(`session ${sessionId} already has a run in flight (${runId})`);
12
+ this.name = "InFlightError";
13
+ this.sessionId = sessionId;
14
+ this.runId = runId;
15
+ }
16
+ }
17
+ /** The log holds a run that never reached its terminal — a previous
18
+ * process died inside it. It must be resumed (recovery drives ONE run to
19
+ * its terminal) before a new turn starts; `run(..., { resumeFirst: true })`
20
+ * does that in one call. The one thing the service never does is resume
21
+ * silently. */
22
+ export class OpenRunError extends Error {
23
+ sessionId;
24
+ runId;
25
+ constructor(sessionId, runId) {
26
+ super(`session ${sessionId} still has an open run (${runId}) — resume it before starting a new turn`);
27
+ this.name = "OpenRunError";
28
+ this.sessionId = sessionId;
29
+ this.runId = runId;
30
+ }
31
+ }
32
+ /** drain() was called: no new run or resume starts in this process. */
33
+ export class DrainingError extends Error {
34
+ constructor() {
35
+ super("the service is draining — no new run starts in this process");
36
+ this.name = "DrainingError";
37
+ }
38
+ }
@@ -0,0 +1,12 @@
1
+ import type { Event } from "@vincemakes/kiso-core";
2
+ /**
3
+ * The events that end a tool execution, stated ONCE.
4
+ *
5
+ * The two products that counted executing tools by hand disagreed on this
6
+ * set: one stopped at succeeded / failed, the other also counted resolved.
7
+ * Resolved belongs here — an uncertain execution that received its verdict
8
+ * is no longer executing, whichever verdict it got.
9
+ */
10
+ export declare const EXECUTION_ENDED: ReadonlySet<Event["type"]>;
11
+ /** +1 when a tool starts executing, −1 when one ends, 0 otherwise. */
12
+ export declare function executionDelta(event: Event): -1 | 0 | 1;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The events that end a tool execution, stated ONCE.
3
+ *
4
+ * The two products that counted executing tools by hand disagreed on this
5
+ * set: one stopped at succeeded / failed, the other also counted resolved.
6
+ * Resolved belongs here — an uncertain execution that received its verdict
7
+ * is no longer executing, whichever verdict it got.
8
+ */
9
+ export const EXECUTION_ENDED = new Set([
10
+ "tool_execution_succeeded",
11
+ "tool_execution_failed",
12
+ "tool_execution_resolved",
13
+ ]);
14
+ /** +1 when a tool starts executing, −1 when one ends, 0 otherwise. */
15
+ export function executionDelta(event) {
16
+ if (event.type === "tool_execution_started")
17
+ return 1;
18
+ return EXECUTION_ENDED.has(event.type) ? -1 : 0;
19
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Event } from "@vincemakes/kiso-core";
3
+ import type { WireFrame, WireInput } from "@vincemakes/kiso-protocol";
4
+ import type { SessionService } from "./service.js";
5
+ import { type ProjectionOptions } from "./wire.js";
6
+ /**
7
+ * The HTTP + SSE transport over the hosted-session service.
8
+ *
9
+ * A host mounts `handle` in front of its own routes: it answers the agent
10
+ * routes under `prefix` and returns false for everything else. Status codes
11
+ * and framing are decided here, once — the two hosting products had each
12
+ * chosen their own (409 for in-flight in one, an exception in the other;
13
+ * `event:` on every frame in one, none in the other; a keepalive in one).
14
+ *
15
+ * GET {prefix}/:id/events the stream, from ?after or Last-Event-ID
16
+ * GET {prefix}/:id the session snapshot (also /state)
17
+ * GET {prefix}/:id/replay every wire event on the log + the snapshot
18
+ * POST {prefix}/:id/run 202 { runId } — or the run's own stream with ?stream=1
19
+ * POST {prefix}/:id/resume 202 { runId } — or the stream with ?stream=1
20
+ * POST {prefix}/:id/abort 200 idle | stopped — 409 parked (who is waited for)
21
+ * POST {prefix}/:id/approve 200 { needsResume }
22
+ * POST {prefix}/:id/uncertain 200 { remaining }
23
+ *
24
+ * What the host supplies: `authorize` (REQUIRED — session ownership; there
25
+ * is no default that allows), and optionally `augment` (frames beside a
26
+ * wire event: billing, estimates), `prepareInput` (the product's turn
27
+ * preparation), the projection options and the keepalive period.
28
+ */
29
+ export interface HttpHandlerOptions {
30
+ /** The mount point; the session id and the action follow it. Default `/v1/sessions`. */
31
+ readonly prefix?: string;
32
+ /** Session ownership, per request. False → 403, and nothing is opened. */
33
+ readonly authorize: (req: IncomingMessage, sessionId: string) => Promise<boolean> | boolean;
34
+ /** Frames a product adds beside a wire event on the same connection. */
35
+ readonly augment?: (event: Event, sessionId: string) => readonly WireFrame[] | Promise<readonly WireFrame[]>;
36
+ /** The product's turn preparation; default: `body.input` as given. */
37
+ readonly prepareInput?: (body: Readonly<Record<string, unknown>>, req: IncomingMessage, sessionId: string) => WireInput | Promise<WireInput>;
38
+ readonly projection?: ProjectionOptions;
39
+ /** `: keepalive` comments on an idle stream. Default 15 s; 0 disables. */
40
+ readonly keepaliveMs?: number;
41
+ /** Request-body cap in bytes. Default 1 MiB. */
42
+ readonly bodyLimit?: number;
43
+ }
44
+ export interface HttpHandler {
45
+ /** True when the request was an agent route and has been answered. */
46
+ readonly handle: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
47
+ }
48
+ export declare function createHttpHandler(service: SessionService, options: HttpHandlerOptions): HttpHandler;
package/dist/http.js ADDED
@@ -0,0 +1,249 @@
1
+ import { DrainingError, InFlightError, OpenRunError } from "./errors.js";
2
+ import { toWireEvent } from "./wire.js";
3
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
4
+ const ACTIONS = new Set(["", "state", "events", "replay", "run", "resume", "abort", "approve", "uncertain"]);
5
+ export function createHttpHandler(service, options) {
6
+ const prefix = (options.prefix ?? "/v1/sessions").replace(/\/+$/, "");
7
+ const keepaliveMs = options.keepaliveMs ?? 15_000;
8
+ const bodyLimit = options.bodyLimit ?? 1024 * 1024;
9
+ const projection = options.projection ?? {};
10
+ const json = (res, status, body) => {
11
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
12
+ res.end(JSON.stringify(body));
13
+ };
14
+ const fail = (res, status, code, message, runId) => {
15
+ const error = { code, message, ...(runId !== undefined ? { runId } : {}) };
16
+ json(res, status, error);
17
+ };
18
+ /** The service's refusals → the wire's codes. */
19
+ const refuse = (res, err) => {
20
+ if (err instanceof InFlightError)
21
+ return fail(res, 409, "in_flight", err.message, err.runId);
22
+ if (err instanceof OpenRunError)
23
+ return fail(res, 409, "open_run", err.message, err.runId);
24
+ if (err instanceof DrainingError)
25
+ return fail(res, 503, "draining", err.message);
26
+ if (err instanceof BadRequest)
27
+ return fail(res, 400, "bad_request", err.message);
28
+ fail(res, 500, "internal", err instanceof Error ? err.message : String(err));
29
+ };
30
+ const readBody = async (req) => {
31
+ const chunks = [];
32
+ let size = 0;
33
+ for await (const chunk of req) {
34
+ const buf = chunk;
35
+ size += buf.length;
36
+ if (size > bodyLimit)
37
+ throw new BadRequest(`body exceeds ${bodyLimit} bytes`);
38
+ chunks.push(buf);
39
+ }
40
+ if (chunks.length === 0)
41
+ return {};
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
45
+ }
46
+ catch {
47
+ throw new BadRequest("body is not JSON");
48
+ }
49
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
50
+ throw new BadRequest("body must be a JSON object");
51
+ return parsed;
52
+ };
53
+ const inputOf = async (body, req, sessionId) => {
54
+ if (options.prepareInput !== undefined)
55
+ return options.prepareInput(body, req, sessionId);
56
+ const input = body["input"];
57
+ if (typeof input === "string") {
58
+ if (input.trim() === "")
59
+ throw new BadRequest("input must be a non-empty string");
60
+ return input;
61
+ }
62
+ if (Array.isArray(input) && input.length > 0)
63
+ return input;
64
+ throw new BadRequest("input must be a non-empty string or a non-empty array of content blocks");
65
+ };
66
+ const state = async (sessionId) => {
67
+ const events = service.events(sessionId);
68
+ return {
69
+ sessionId,
70
+ running: service.isRunning(sessionId),
71
+ highWater: events.at(-1)?.seq ?? -1,
72
+ openRun: service.openRun(sessionId),
73
+ pendingApprovals: await service.pendingApprovals(sessionId),
74
+ uncertain: (await service.uncertainExecutions(sessionId)).map((u) => ({ executionId: u.executionId, callId: u.callId, name: u.name })),
75
+ };
76
+ };
77
+ /** One durable event → its frames: the wire event (if any) under its seq, then the product's. */
78
+ const framesOf = async (event, sessionId) => {
79
+ let out = "";
80
+ const wire = toWireEvent(event, projection);
81
+ if (wire !== null)
82
+ out += `id: ${wire.seq}\nevent: ${wire.type}\ndata: ${JSON.stringify(wire)}\n\n`;
83
+ if (options.augment !== undefined) {
84
+ for (const frame of await options.augment(event, sessionId))
85
+ out += `event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`;
86
+ }
87
+ return out;
88
+ };
89
+ /** Open an SSE response and pump the session's events after `after`
90
+ * into it until the client leaves or `until` settles. */
91
+ const stream = async (res, req, sessionId, after, until) => {
92
+ res.writeHead(200, {
93
+ "content-type": "text/event-stream; charset=utf-8",
94
+ "cache-control": "no-cache, no-transform",
95
+ connection: "keep-alive",
96
+ "x-accel-buffering": "no",
97
+ });
98
+ res.write(": open\n\n");
99
+ // frames are written in event order even though augment is async:
100
+ // a chain serialises them
101
+ let chain = Promise.resolve();
102
+ let closed = false;
103
+ const unsubscribe = await service.subscribe(sessionId, after, (event) => {
104
+ chain = chain.then(async () => {
105
+ if (closed)
106
+ return;
107
+ const text = await framesOf(event, sessionId);
108
+ if (!closed && text !== "")
109
+ res.write(text);
110
+ });
111
+ });
112
+ const keepalive = keepaliveMs > 0 ? setInterval(() => res.write(": keepalive\n\n"), keepaliveMs) : undefined;
113
+ keepalive?.unref();
114
+ const end = () => {
115
+ if (closed)
116
+ return;
117
+ closed = true;
118
+ if (keepalive !== undefined)
119
+ clearInterval(keepalive);
120
+ unsubscribe();
121
+ };
122
+ req.on("close", end);
123
+ res.on("close", end);
124
+ if (until !== undefined) {
125
+ await until.catch(() => { });
126
+ await chain; // every frame of the run has been written
127
+ end();
128
+ res.end();
129
+ }
130
+ };
131
+ const afterOf = (req, url, body) => {
132
+ const header = req.headers["last-event-id"];
133
+ const fromHeader = typeof header === "string" ? Number(header) : NaN;
134
+ if (Number.isFinite(fromHeader))
135
+ return fromHeader;
136
+ const fromBody = body !== undefined && typeof body["after"] === "number" ? body["after"] : NaN;
137
+ if (Number.isFinite(fromBody))
138
+ return fromBody;
139
+ const fromQuery = Number(url.searchParams.get("after") ?? NaN);
140
+ return Number.isFinite(fromQuery) ? fromQuery : -1;
141
+ };
142
+ const handle = async (req, res) => {
143
+ const url = new URL(req.url ?? "/", "http://localhost");
144
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`))
145
+ return false;
146
+ const rest = url.pathname.slice(prefix.length + 1);
147
+ const slash = rest.indexOf("/");
148
+ const rawId = slash === -1 ? rest : rest.slice(0, slash);
149
+ const action = slash === -1 ? "" : rest.slice(slash + 1);
150
+ let sessionId;
151
+ try {
152
+ sessionId = decodeURIComponent(rawId);
153
+ }
154
+ catch {
155
+ fail(res, 400, "bad_request", "malformed session id");
156
+ return true;
157
+ }
158
+ if (!SESSION_ID.test(sessionId) || !ACTIONS.has(action)) {
159
+ fail(res, 404, "not_found", "no such route");
160
+ return true;
161
+ }
162
+ try {
163
+ if (!(await options.authorize(req, sessionId))) {
164
+ fail(res, 403, "forbidden", "this session is not yours");
165
+ return true;
166
+ }
167
+ const method = req.method ?? "GET";
168
+ if (method === "GET" && action === "events") {
169
+ await stream(res, req, sessionId, afterOf(req, url));
170
+ return true;
171
+ }
172
+ if (method === "GET" && (action === "" || action === "state")) {
173
+ json(res, 200, await state(sessionId));
174
+ return true;
175
+ }
176
+ if (method === "GET" && action === "replay") {
177
+ const events = service.events(sessionId).map((e) => toWireEvent(e, projection)).filter((w) => w !== null);
178
+ json(res, 200, { sessionId, events, state: await state(sessionId) });
179
+ return true;
180
+ }
181
+ if (method !== "POST") {
182
+ fail(res, 405, "bad_request", `${method} is not allowed on ${action || "the session"}`);
183
+ return true;
184
+ }
185
+ const body = await readBody(req);
186
+ const wantsStream = url.searchParams.get("stream") === "1";
187
+ if (action === "run" || action === "resume") {
188
+ let handle;
189
+ if (action === "run") {
190
+ const input = await inputOf(body, req, sessionId);
191
+ const source = body["source"];
192
+ handle = await service.run(sessionId, input, {
193
+ ...(source === "user" || source === "suggestion" || source === "tool_result" ? { source: source } : {}),
194
+ ...(body["resumeFirst"] === true ? { resumeFirst: true } : {}),
195
+ });
196
+ }
197
+ else {
198
+ handle = await service.resume(sessionId);
199
+ }
200
+ if (wantsStream)
201
+ await stream(res, req, sessionId, afterOf(req, url, body), handle.done);
202
+ else
203
+ json(res, 202, { runId: handle.runId });
204
+ return true;
205
+ }
206
+ if (action === "abort") {
207
+ const outcome = await service.abort(sessionId, body["force"] === true ? { force: true } : {});
208
+ const reply = outcome.kind === "stopped" ? { kind: "stopped", runId: outcome.runId } : outcome;
209
+ json(res, outcome.kind === "parked" ? 409 : 200, reply);
210
+ return true;
211
+ }
212
+ if (action === "approve") {
213
+ const decisionId = body["decisionId"];
214
+ const allow = body["allow"];
215
+ if (typeof decisionId !== "string" || decisionId === "" || typeof allow !== "boolean")
216
+ throw new BadRequest("decisionId (string) and allow (boolean) are required");
217
+ const reason = body["reason"];
218
+ json(res, 200, await service.approve(sessionId, decisionId, allow, typeof reason === "string" ? reason : undefined));
219
+ return true;
220
+ }
221
+ if (action === "uncertain") {
222
+ const executionId = body["executionId"];
223
+ const resolution = body["resolution"];
224
+ if (typeof executionId !== "string" || executionId === "" || (resolution !== "rerun" && resolution !== "abandoned")) {
225
+ throw new BadRequest('executionId (string) and resolution ("rerun" | "abandoned") are required');
226
+ }
227
+ json(res, 200, await service.resolveUncertain(sessionId, executionId, resolution));
228
+ return true;
229
+ }
230
+ fail(res, 404, "not_found", "no such route");
231
+ return true;
232
+ }
233
+ catch (err) {
234
+ if (res.headersSent) {
235
+ res.end();
236
+ return true;
237
+ }
238
+ refuse(res, err);
239
+ return true;
240
+ }
241
+ };
242
+ return { handle };
243
+ }
244
+ class BadRequest extends Error {
245
+ constructor(message) {
246
+ super(message);
247
+ this.name = "BadRequest";
248
+ }
249
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @vincemakes/kiso-server — the hosted-session service.
3
+ *
4
+ * Many durable sessions in one process; observers with exact replay from
5
+ * a sequence number; approve / abort / resume routed to the right run;
6
+ * drain and close. No transport, no product noun: the product supplies
7
+ * the agent factory, and every run is the runtime's own `Session.run()`.
8
+ */
9
+ export { createSessionService, SessionService } from "./service.js";
10
+ export type { AbortOutcome, DrainReport, OpenRun, RunHandle, RunOptions, SessionServiceOptions } from "./service.js";
11
+ export { DrainingError, InFlightError, OpenRunError } from "./errors.js";
12
+ export { EXECUTION_ENDED, executionDelta } from "./execution.js";
13
+ export { tail } from "./tail.js";
14
+ export { MAX_ARG_VALUE_CHARS, STRIPPED_ARG_KEYS, sanitizeToolArgs, toWireEvent } from "./wire.js";
15
+ export type { ProjectionOptions } from "./wire.js";
16
+ export type { Listener } from "./tail.js";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @vincemakes/kiso-server — the hosted-session service.
3
+ *
4
+ * Many durable sessions in one process; observers with exact replay from
5
+ * a sequence number; approve / abort / resume routed to the right run;
6
+ * drain and close. No transport, no product noun: the product supplies
7
+ * the agent factory, and every run is the runtime's own `Session.run()`.
8
+ */
9
+ export { createSessionService, SessionService } from "./service.js";
10
+ export { DrainingError, InFlightError, OpenRunError } from "./errors.js";
11
+ export { EXECUTION_ENDED, executionDelta } from "./execution.js";
12
+ export { tail } from "./tail.js";
13
+ export { MAX_ARG_VALUE_CHARS, STRIPPED_ARG_KEYS, sanitizeToolArgs, toWireEvent } from "./wire.js";
@@ -0,0 +1,150 @@
1
+ import type { ContentBlock, Event, MessageSource } from "@vincemakes/kiso-core";
2
+ import type { Agent, ApprovalRequest, Session, SessionStore } from "@vincemakes/kiso-runtime";
3
+ import { type Listener } from "./tail.js";
4
+ /**
5
+ * The hosted-session service — many durable sessions in one long-lived
6
+ * process, each with observers, one run at a time.
7
+ *
8
+ * What it is: the layer two products wrote independently on top of the
9
+ * runtime (a session registry, the one-run rule, a pump that fans a run's
10
+ * events out to listeners and counts executing tools, replay-from-seq for
11
+ * reconnecting observers, approve / abort / resume routed to the right
12
+ * run, drain before exit). It names no product noun: the product hands
13
+ * in an agent FACTORY (control inversion) and the service never learns
14
+ * what the agent is for.
15
+ *
16
+ * What it is not: a transport. HTTP, SSE, sockets and their status codes
17
+ * live above it. It is also not a second Agent — every session here is
18
+ * `runtime.createAgent(...).session({ id })`, and every run is that
19
+ * session's `run()` / `resume()`; the service adds bookkeeping, never a
20
+ * loop of its own.
21
+ */
22
+ export interface SessionServiceOptions {
23
+ /** The store every session in this process lives on — the same one the
24
+ * factory's agents write to. The service reads it for replay and for
25
+ * the open-run check. */
26
+ readonly store: SessionStore;
27
+ /** The product's agent factory, called once per session id (and again by
28
+ * `reopen`). Everything product-specific — prompt, tools, extensions,
29
+ * permission policy — is decided in here. */
30
+ readonly open: (sessionId: string) => Promise<Agent>;
31
+ readonly hooks?: {
32
+ /** Product-level parking beyond the kernel's own approvals — the ask
33
+ * extension's pending questions are the known case. A non-empty
34
+ * list means the run is waiting for a person, so `abort()` refuses
35
+ * (a voided draft is not what "stop" means when someone is being
36
+ * asked) unless forced. */
37
+ readonly parkedBy?: (sessionId: string) => readonly string[];
38
+ /** After `abort()` has aborted a run: the product's cleanup, e.g.
39
+ * finishing paid jobs the run left in flight. */
40
+ readonly onAbort?: (sessionId: string, runId: string) => Promise<void> | void;
41
+ };
42
+ }
43
+ export interface RunHandle {
44
+ readonly runId: string;
45
+ /** Settles when the run has reached its terminal and the last event has
46
+ * been delivered to every listener. Rejects only if the runtime threw
47
+ * (a poisoned session); a run that fails still SETTLES — its failure is
48
+ * a terminal event, not an exception. */
49
+ readonly done: Promise<void>;
50
+ }
51
+ export interface RunOptions {
52
+ readonly source?: MessageSource;
53
+ /** The log holds an open run from a previous process: resume it to its
54
+ * terminal first, then start this turn. Without this the call refuses
55
+ * with `OpenRunError` — the service never resumes silently. */
56
+ readonly resumeFirst?: boolean;
57
+ }
58
+ export type AbortOutcome = {
59
+ readonly kind: "idle";
60
+ } | {
61
+ readonly kind: "parked";
62
+ readonly runId: string;
63
+ readonly approvals: readonly ApprovalRequest[];
64
+ readonly reasons: readonly string[];
65
+ } | {
66
+ readonly kind: "stopped";
67
+ readonly runId: string;
68
+ readonly settled: Promise<void>;
69
+ };
70
+ export interface DrainReport {
71
+ /** Sessions with a tool executing when drain began; waited for, up to the grace. */
72
+ readonly waitedFor: readonly string[];
73
+ /** Sessions whose run is waiting for a person — untouched, resumable. */
74
+ readonly parked: readonly string[];
75
+ /** Sessions streaming (no tool executing) — cut when the process exits, resumable. */
76
+ readonly interrupted: readonly string[];
77
+ /** The subset of `waitedFor` still executing when the grace ran out. */
78
+ readonly timedOut: readonly string[];
79
+ }
80
+ export interface OpenRun {
81
+ readonly sessionId: string;
82
+ readonly runId: string;
83
+ }
84
+ export declare class SessionService {
85
+ #private;
86
+ constructor(options: SessionServiceOptions);
87
+ /** Rebuild an IDLE session from the factory — the product's definition
88
+ * changed (its tool set, its prompt) and the next run should see it.
89
+ * Observers are carried over. Refuses while a run is in flight. */
90
+ reopen(sessionId: string): Promise<void>;
91
+ /** Start a turn. Refuses with `InFlightError` (one run per session),
92
+ * `OpenRunError` (a previous process died inside a run — see
93
+ * `resumeFirst`) or `DrainingError`. */
94
+ run(sessionId: string, input: string | readonly ContentBlock[], options?: RunOptions): Promise<RunHandle>;
95
+ /** Drive the open run (one a previous process left, or one parked at an
96
+ * approval that has since been answered) to its terminal. */
97
+ resume(sessionId: string): Promise<RunHandle>;
98
+ /** Stop the run in flight. A run waiting for a person (a kernel approval,
99
+ * or whatever `hooks.parkedBy` names) is PARKED, not running: aborting
100
+ * it would void the draft the answer is meant to continue, so the
101
+ * service refuses and says who is being waited for — `force` overrides.
102
+ * "stopped" means the abort was delivered; `settled` resolves when the
103
+ * run's terminal is written (a tool that ignores the abort signal keeps
104
+ * the run open until it returns — the kernel does not kill tools). The
105
+ * `onAbort` hook runs on the abort, not on the settle. */
106
+ abort(sessionId: string, options?: {
107
+ readonly force?: boolean;
108
+ }): Promise<AbortOutcome>;
109
+ /** Answer a kernel approval. `needsResume` is true when no run was live
110
+ * to consume the answer (the process that asked is gone): the decision
111
+ * is durable, and `resume()` is what makes the run continue. */
112
+ approve(sessionId: string, decisionId: string, allow: boolean, reason?: string): Promise<{
113
+ readonly needsResume: boolean;
114
+ }>;
115
+ pendingApprovals(sessionId: string): Promise<readonly ApprovalRequest[]>;
116
+ uncertainExecutions(sessionId: string): Promise<ReturnType<Session["uncertainExecutions"]>>;
117
+ /** Record a verdict on an uncertain execution. `remaining` is how many
118
+ * still await one; the host resumes when it reaches zero — the service
119
+ * does not start runs on its own. */
120
+ resolveUncertain(sessionId: string, executionId: string, resolution: "rerun" | "abandoned"): Promise<{
121
+ readonly remaining: number;
122
+ }>;
123
+ /** Every event after `after` — the ones on disk now, then the live ones
124
+ * as they land — exactly once each, in order. Returns the unsubscribe. */
125
+ subscribe(sessionId: string, after: number, listener: Listener): Promise<() => void>;
126
+ /** The durable log after `after` — a plain read, no session opened. */
127
+ events(sessionId: string, after?: number): Event[];
128
+ isRunning(sessionId: string): boolean;
129
+ /** The highest seq this process has delivered for the session, −1 before any. */
130
+ highWater(sessionId: string): number;
131
+ executingCount(): number;
132
+ get draining(): boolean;
133
+ /** The run a previous process left without a terminal on this session,
134
+ * or null. The plain read the transport's snapshot needs. */
135
+ openRun(sessionId: string): string | null;
136
+ /** Boot recovery: every session on the store whose last run has no
137
+ * terminal. The host decides what to do with them (resume, or leave
138
+ * for the next request to find). */
139
+ openRuns(): OpenRun[];
140
+ /** Stop accepting runs; wait up to `graceMs` for executing tools; report.
141
+ * Nothing is aborted here — a run cut by the process exiting is
142
+ * resumable, and the report says which sessions those are. */
143
+ drain(graceMs: number): Promise<DrainReport>;
144
+ /** Abort every live run, wait up to `graceMs` for each to settle (a
145
+ * tool that ignores the abort keeps its run open until it returns),
146
+ * then forget every session. Whatever did not settle is resumable by
147
+ * the next process — persist-first means nothing delivered is lost. */
148
+ close(graceMs?: number): Promise<void>;
149
+ }
150
+ export declare function createSessionService(options: SessionServiceOptions): SessionService;
@@ -0,0 +1,263 @@
1
+ import { openRunId } from "@vincemakes/kiso-runtime/internal";
2
+ import { DrainingError, InFlightError, OpenRunError } from "./errors.js";
3
+ import { executionDelta } from "./execution.js";
4
+ import { tail } from "./tail.js";
5
+ export class SessionService {
6
+ #store;
7
+ #open;
8
+ #hooks;
9
+ /** id → the opening (memoized, so two concurrent callers share one open). */
10
+ #opening = new Map();
11
+ /** id → the held session, once open. */
12
+ #held = new Map();
13
+ #draining = false;
14
+ constructor(options) {
15
+ this.#store = options.store;
16
+ this.#open = options.open;
17
+ this.#hooks = options.hooks ?? {};
18
+ }
19
+ // ---- the registry -------------------------------------------------------
20
+ async #hold(sessionId) {
21
+ const existing = this.#opening.get(sessionId);
22
+ if (existing !== undefined)
23
+ return existing;
24
+ const opening = (async () => {
25
+ const agent = await this.#open(sessionId);
26
+ const session = await agent.session({ id: sessionId });
27
+ const held = { id: sessionId, agent, session, listeners: new Set(), live: null, pumping: null, executing: 0, highWater: -1 };
28
+ this.#held.set(sessionId, held);
29
+ return held;
30
+ })();
31
+ this.#opening.set(sessionId, opening);
32
+ try {
33
+ return await opening;
34
+ }
35
+ catch (err) {
36
+ this.#opening.delete(sessionId);
37
+ throw err;
38
+ }
39
+ }
40
+ /** Rebuild an IDLE session from the factory — the product's definition
41
+ * changed (its tool set, its prompt) and the next run should see it.
42
+ * Observers are carried over. Refuses while a run is in flight. */
43
+ async reopen(sessionId) {
44
+ const held = await this.#hold(sessionId);
45
+ if (held.live !== null)
46
+ throw new InFlightError(sessionId, held.live.runId);
47
+ const agent = await this.#open(sessionId);
48
+ held.agent = agent;
49
+ held.session = await agent.session({ id: sessionId });
50
+ }
51
+ // ---- runs -----------------------------------------------------------------
52
+ #pump(held, run) {
53
+ held.live = run;
54
+ const pumping = (async () => {
55
+ try {
56
+ for await (const event of run) {
57
+ held.executing = Math.max(0, held.executing + executionDelta(event));
58
+ held.highWater = Math.max(held.highWater, event.seq);
59
+ for (const listener of [...held.listeners]) {
60
+ try {
61
+ listener(event);
62
+ }
63
+ catch {
64
+ held.listeners.delete(listener); // a throwing observer leaves; the run does not
65
+ }
66
+ }
67
+ }
68
+ }
69
+ finally {
70
+ if (held.live === run) {
71
+ held.live = null;
72
+ held.pumping = null;
73
+ held.executing = 0;
74
+ }
75
+ }
76
+ })();
77
+ held.pumping = pumping;
78
+ void pumping.catch(() => { }); // a host that does not await `done` gets no unhandled rejection
79
+ return { runId: run.runId, done: pumping };
80
+ }
81
+ /** Start a turn. Refuses with `InFlightError` (one run per session),
82
+ * `OpenRunError` (a previous process died inside a run — see
83
+ * `resumeFirst`) or `DrainingError`. */
84
+ async run(sessionId, input, options = {}) {
85
+ if (this.#draining)
86
+ throw new DrainingError();
87
+ const held = await this.#hold(sessionId);
88
+ if (held.live !== null)
89
+ throw new InFlightError(sessionId, held.live.runId);
90
+ const open = openRunId(this.#store.load(sessionId));
91
+ if (open !== undefined) {
92
+ if (options.resumeFirst !== true)
93
+ throw new OpenRunError(sessionId, open);
94
+ await this.#pump(held, held.session.resume()).done;
95
+ }
96
+ const run = held.session.run(input, options.source !== undefined ? { source: options.source } : {});
97
+ return this.#pump(held, run);
98
+ }
99
+ /** Drive the open run (one a previous process left, or one parked at an
100
+ * approval that has since been answered) to its terminal. */
101
+ async resume(sessionId) {
102
+ if (this.#draining)
103
+ throw new DrainingError();
104
+ const held = await this.#hold(sessionId);
105
+ if (held.live !== null)
106
+ throw new InFlightError(sessionId, held.live.runId);
107
+ return this.#pump(held, held.session.resume());
108
+ }
109
+ /** Stop the run in flight. A run waiting for a person (a kernel approval,
110
+ * or whatever `hooks.parkedBy` names) is PARKED, not running: aborting
111
+ * it would void the draft the answer is meant to continue, so the
112
+ * service refuses and says who is being waited for — `force` overrides.
113
+ * "stopped" means the abort was delivered; `settled` resolves when the
114
+ * run's terminal is written (a tool that ignores the abort signal keeps
115
+ * the run open until it returns — the kernel does not kill tools). The
116
+ * `onAbort` hook runs on the abort, not on the settle. */
117
+ async abort(sessionId, options = {}) {
118
+ const held = await this.#hold(sessionId);
119
+ const run = held.live;
120
+ if (run === null)
121
+ return { kind: "idle" };
122
+ const approvals = held.session.pendingApprovals();
123
+ const reasons = this.#hooks.parkedBy?.(sessionId) ?? [];
124
+ if ((approvals.length > 0 || reasons.length > 0) && options.force !== true) {
125
+ return { kind: "parked", runId: run.runId, approvals, reasons };
126
+ }
127
+ run.abort();
128
+ const settled = held.pumping?.catch(() => { }) ?? Promise.resolve();
129
+ await this.#hooks.onAbort?.(sessionId, run.runId);
130
+ return { kind: "stopped", runId: run.runId, settled };
131
+ }
132
+ // ---- decisions ------------------------------------------------------------
133
+ /** Answer a kernel approval. `needsResume` is true when no run was live
134
+ * to consume the answer (the process that asked is gone): the decision
135
+ * is durable, and `resume()` is what makes the run continue. */
136
+ async approve(sessionId, decisionId, allow, reason) {
137
+ const held = await this.#hold(sessionId);
138
+ const wasLive = held.live !== null;
139
+ await held.session.approve(decisionId, allow, reason);
140
+ return { needsResume: !wasLive };
141
+ }
142
+ async pendingApprovals(sessionId) {
143
+ return (await this.#hold(sessionId)).session.pendingApprovals();
144
+ }
145
+ async uncertainExecutions(sessionId) {
146
+ return (await this.#hold(sessionId)).session.uncertainExecutions();
147
+ }
148
+ /** Record a verdict on an uncertain execution. `remaining` is how many
149
+ * still await one; the host resumes when it reaches zero — the service
150
+ * does not start runs on its own. */
151
+ async resolveUncertain(sessionId, executionId, resolution) {
152
+ const held = await this.#hold(sessionId);
153
+ await held.session.resolveUncertain(executionId, resolution);
154
+ return { remaining: held.session.uncertainExecutions().length };
155
+ }
156
+ // ---- observation ----------------------------------------------------------
157
+ /** Every event after `after` — the ones on disk now, then the live ones
158
+ * as they land — exactly once each, in order. Returns the unsubscribe. */
159
+ async subscribe(sessionId, after, listener) {
160
+ const held = await this.#hold(sessionId);
161
+ return tail(() => this.events(sessionId, after), held.listeners, after, listener);
162
+ }
163
+ /** The durable log after `after` — a plain read, no session opened. */
164
+ events(sessionId, after = -1) {
165
+ return this.#store
166
+ .load(sessionId)
167
+ .map((r) => r.event)
168
+ .filter((e) => e.seq > after);
169
+ }
170
+ isRunning(sessionId) {
171
+ return this.#held.get(sessionId)?.live !== null && this.#held.get(sessionId)?.live !== undefined;
172
+ }
173
+ /** The highest seq this process has delivered for the session, −1 before any. */
174
+ highWater(sessionId) {
175
+ return this.#held.get(sessionId)?.highWater ?? -1;
176
+ }
177
+ executingCount() {
178
+ let n = 0;
179
+ for (const held of this.#held.values())
180
+ n += held.executing;
181
+ return n;
182
+ }
183
+ get draining() {
184
+ return this.#draining;
185
+ }
186
+ /** The run a previous process left without a terminal on this session,
187
+ * or null. The plain read the transport's snapshot needs. */
188
+ openRun(sessionId) {
189
+ return openRunId(this.#store.load(sessionId)) ?? null;
190
+ }
191
+ /** Boot recovery: every session on the store whose last run has no
192
+ * terminal. The host decides what to do with them (resume, or leave
193
+ * for the next request to find). */
194
+ openRuns() {
195
+ const out = [];
196
+ for (const sessionId of this.#store.ids()) {
197
+ const runId = openRunId(this.#store.load(sessionId));
198
+ if (runId !== undefined)
199
+ out.push({ sessionId, runId });
200
+ }
201
+ return out;
202
+ }
203
+ // ---- process lifecycle -------------------------------------------------
204
+ /** Stop accepting runs; wait up to `graceMs` for executing tools; report.
205
+ * Nothing is aborted here — a run cut by the process exiting is
206
+ * resumable, and the report says which sessions those are. */
207
+ async drain(graceMs) {
208
+ this.#draining = true;
209
+ const waitedFor = [];
210
+ const parked = [];
211
+ const interrupted = [];
212
+ const waits = [];
213
+ for (const held of this.#held.values()) {
214
+ if (held.live === null)
215
+ continue;
216
+ if (held.executing > 0) {
217
+ waitedFor.push(held.id);
218
+ if (held.pumping !== null)
219
+ waits.push(held.pumping.catch(() => { }));
220
+ continue;
221
+ }
222
+ const askedFor = held.session.pendingApprovals().length > 0 || (this.#hooks.parkedBy?.(held.id) ?? []).length > 0;
223
+ (askedFor ? parked : interrupted).push(held.id);
224
+ }
225
+ if (waits.length === 0)
226
+ return { waitedFor, parked, interrupted, timedOut: [] };
227
+ let timer;
228
+ const deadline = new Promise((resolve) => {
229
+ timer = setTimeout(() => resolve("timeout"), graceMs);
230
+ });
231
+ const outcome = await Promise.race([Promise.allSettled(waits).then(() => "done"), deadline]);
232
+ if (timer !== undefined)
233
+ clearTimeout(timer);
234
+ const timedOut = outcome === "timeout" ? waitedFor.filter((id) => (this.#held.get(id)?.executing ?? 0) > 0) : [];
235
+ return { waitedFor, parked, interrupted, timedOut };
236
+ }
237
+ /** Abort every live run, wait up to `graceMs` for each to settle (a
238
+ * tool that ignores the abort keeps its run open until it returns),
239
+ * then forget every session. Whatever did not settle is resumable by
240
+ * the next process — persist-first means nothing delivered is lost. */
241
+ async close(graceMs = 0) {
242
+ this.#draining = true;
243
+ const settling = [];
244
+ for (const held of this.#held.values()) {
245
+ held.live?.abort();
246
+ if (held.pumping !== null)
247
+ settling.push(held.pumping.catch(() => { }));
248
+ }
249
+ if (settling.length > 0 && graceMs > 0) {
250
+ let timer;
251
+ await Promise.race([Promise.all(settling), new Promise((resolve) => {
252
+ timer = setTimeout(resolve, graceMs);
253
+ })]);
254
+ if (timer !== undefined)
255
+ clearTimeout(timer);
256
+ }
257
+ this.#held.clear();
258
+ this.#opening.clear();
259
+ }
260
+ }
261
+ export function createSessionService(options) {
262
+ return new SessionService(options);
263
+ }
package/dist/tail.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Event } from "@vincemakes/kiso-core";
2
+ export type Listener = (event: Event) => void;
3
+ /**
4
+ * Replay-then-live, exact by sequence number.
5
+ *
6
+ * The reconnect guarantee: a subscriber that names the last seq it saw
7
+ * receives every event after it exactly once — the ones already on disk
8
+ * (persist-first: every event is written before a run yields it) and the
9
+ * ones the live run emits from now on, in order, with no duplicate at the
10
+ * seam. The seam is the only hard part: an event the run emits WHILE the
11
+ * replay is being read must neither be lost nor sent twice. It is
12
+ * buffered until the replay ends, then sent only if its seq is beyond
13
+ * what the replay covered.
14
+ *
15
+ * Both hosting products wrote this function independently, flag names
16
+ * included. It is one function now.
17
+ */
18
+ export declare function tail(replay: () => Iterable<Event>, listeners: Set<Listener>, after: number, send: Listener): () => void;
package/dist/tail.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Replay-then-live, exact by sequence number.
3
+ *
4
+ * The reconnect guarantee: a subscriber that names the last seq it saw
5
+ * receives every event after it exactly once — the ones already on disk
6
+ * (persist-first: every event is written before a run yields it) and the
7
+ * ones the live run emits from now on, in order, with no duplicate at the
8
+ * seam. The seam is the only hard part: an event the run emits WHILE the
9
+ * replay is being read must neither be lost nor sent twice. It is
10
+ * buffered until the replay ends, then sent only if its seq is beyond
11
+ * what the replay covered.
12
+ *
13
+ * Both hosting products wrote this function independently, flag names
14
+ * included. It is one function now.
15
+ */
16
+ export function tail(replay, listeners, after, send) {
17
+ let sentUpTo = after;
18
+ const buffered = [];
19
+ let replaying = true;
20
+ const listener = (event) => {
21
+ if (replaying) {
22
+ buffered.push(event);
23
+ return;
24
+ }
25
+ if (event.seq <= sentUpTo)
26
+ return;
27
+ sentUpTo = event.seq;
28
+ send(event);
29
+ };
30
+ listeners.add(listener);
31
+ for (const event of replay()) {
32
+ if (event.seq <= sentUpTo)
33
+ continue;
34
+ sentUpTo = event.seq;
35
+ send(event);
36
+ }
37
+ replaying = false;
38
+ for (const event of buffered) {
39
+ if (event.seq <= sentUpTo)
40
+ continue;
41
+ sentUpTo = event.seq;
42
+ send(event);
43
+ }
44
+ return () => {
45
+ listeners.delete(listener);
46
+ };
47
+ }
package/dist/wire.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Event } from "@vincemakes/kiso-core";
2
+ import { type WireEvent } from "@vincemakes/kiso-protocol";
3
+ /**
4
+ * The projection: durable Event → WireEvent | null.
5
+ *
6
+ * Pure, total over the union: a type not in DURABLE_TO_WIRE yields null; a
7
+ * type on the wire yields exactly the allowlisted fields that are present
8
+ * (never undefined), with tool arguments passed through `sanitize`. The
9
+ * allowlist is data in the protocol package; nothing here can add a field
10
+ * the protocol did not name.
11
+ */
12
+ export interface ProjectionOptions {
13
+ /** Reasoning text on the wire — OFF unless a product asks (every hosting
14
+ * product shipped a "thinking leaks" fix once). */
15
+ readonly exposeThinking?: boolean;
16
+ /** Replaces the default tool-argument sanitizer. */
17
+ readonly sanitize?: (input: unknown) => unknown;
18
+ }
19
+ /** Argument keys that carry prose the client has no business seeing in a
20
+ * tool card (the model's own prompt to a generator, a document body).
21
+ * uooki's list, verbatim. */
22
+ export declare const STRIPPED_ARG_KEYS: ReadonlySet<string>;
23
+ /** Longer string values are cut here; the card shows a prefix. */
24
+ export declare const MAX_ARG_VALUE_CHARS = 200;
25
+ /** The default sanitizer: drops the prose keys, truncates long strings,
26
+ * leaves non-objects alone. Shallow — an argument's nested object is
27
+ * passed through as is. */
28
+ export declare function sanitizeToolArgs(input: unknown): unknown;
29
+ export declare function toWireEvent(event: Event, options?: ProjectionOptions): WireEvent | null;
package/dist/wire.js ADDED
@@ -0,0 +1,40 @@
1
+ import { DURABLE_TO_WIRE, SANITIZED_FIELDS, WIRE_FIELDS } from "@vincemakes/kiso-protocol";
2
+ /** Argument keys that carry prose the client has no business seeing in a
3
+ * tool card (the model's own prompt to a generator, a document body).
4
+ * uooki's list, verbatim. */
5
+ export const STRIPPED_ARG_KEYS = new Set(["prompt", "description", "text", "body", "content", "instructions", "system_prompt", "input_text"]);
6
+ /** Longer string values are cut here; the card shows a prefix. */
7
+ export const MAX_ARG_VALUE_CHARS = 200;
8
+ /** The default sanitizer: drops the prose keys, truncates long strings,
9
+ * leaves non-objects alone. Shallow — an argument's nested object is
10
+ * passed through as is. */
11
+ export function sanitizeToolArgs(input) {
12
+ if (input === null || typeof input !== "object" || Array.isArray(input))
13
+ return input;
14
+ const out = {};
15
+ for (const [key, value] of Object.entries(input)) {
16
+ if (STRIPPED_ARG_KEYS.has(key))
17
+ continue;
18
+ out[key] = typeof value === "string" && value.length > MAX_ARG_VALUE_CHARS ? `${value.slice(0, MAX_ARG_VALUE_CHARS)}…` : value;
19
+ }
20
+ return out;
21
+ }
22
+ export function toWireEvent(event, options = {}) {
23
+ const wireType = DURABLE_TO_WIRE[event.type];
24
+ if (wireType === undefined)
25
+ return null;
26
+ if (wireType === "thinking" && options.exposeThinking !== true)
27
+ return null;
28
+ const sanitize = options.sanitize ?? sanitizeToolArgs;
29
+ const source = event;
30
+ const projected = { seq: event.seq, type: wireType };
31
+ for (const field of WIRE_FIELDS[wireType]) {
32
+ if (!(field in source))
33
+ continue;
34
+ const value = source[field];
35
+ if (value === undefined)
36
+ continue;
37
+ projected[field] = SANITIZED_FIELDS.has(field) ? sanitize(value) : value;
38
+ }
39
+ return projected;
40
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@vincemakes/kiso-server",
3
+ "version": "0.41.0",
4
+ "description": "kiso server — the hosted-session service: many durable sessions in one process, observers with exact replay from a sequence number, approve / abort / resume routed to the right run, drain and close. No transport; a product supplies the agent factory.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./http": {
13
+ "types": "./dist/http.d.ts",
14
+ "default": "./dist/http.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "typecheck": "tsc -p tsconfig.json",
25
+ "test": "vitest run"
26
+ },
27
+ "dependencies": {
28
+ "@vincemakes/kiso-core": "0.41.0",
29
+ "@vincemakes/kiso-protocol": "0.41.0",
30
+ "@vincemakes/kiso-runtime": "0.41.0"
31
+ },
32
+ "devDependencies": {
33
+ "@vincemakes/kiso-evals": "0.41.0",
34
+ "@types/node": "^26.1.2",
35
+ "typescript": "^5.7.2",
36
+ "vitest": "^3.0.0"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/vincemakes/kiso.git",
41
+ "directory": "packages/server"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/vincemakes/kiso/issues"
45
+ },
46
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/server#readme"
47
+ }