stitchkit 0.95.0 → 0.95.2

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.
@@ -0,0 +1,221 @@
1
+ import {
2
+ isRecord
3
+ } from "./index-hffjpmq9.js";
4
+
5
+ // src/application/file-state-store.ts
6
+ import { mkdir, open, readdir, readFile, rename, stat, unlink } from "node:fs/promises";
7
+ import { basename, dirname, join } from "node:path";
8
+ var ABANDONED_LOCK_MULTIPLIER = 10;
9
+ var ABANDONED_HEARTBEATS = 30;
10
+ var MIN_HEARTBEAT_MS = 10;
11
+ var delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
12
+ async function missing(error) {
13
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
14
+ }
15
+ function existing(error) {
16
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
17
+ }
18
+ async function ownerState(lockPath) {
19
+ let owner;
20
+ try {
21
+ owner = JSON.parse(await readFile(lockPath, "utf8"));
22
+ } catch {
23
+ return "unknown";
24
+ }
25
+ if (typeof owner !== "object" || owner === null || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
26
+ return "unknown";
27
+ }
28
+ try {
29
+ process.kill(owner.pid, 0);
30
+ return "alive";
31
+ } catch (error) {
32
+ return error instanceof Error && "code" in error && error.code === "ESRCH" ? "gone" : "unknown";
33
+ }
34
+ }
35
+ async function sweepTemporaries(path, staleLockMs) {
36
+ const prefix = `${basename(path)}.tmp.`;
37
+ const directory = dirname(path);
38
+ let entries;
39
+ try {
40
+ entries = await readdir(directory);
41
+ } catch {
42
+ return;
43
+ }
44
+ for (const entry of entries) {
45
+ if (!entry.startsWith(prefix))
46
+ continue;
47
+ const candidate = join(directory, entry);
48
+ const info = await stat(candidate).catch(() => null);
49
+ if (info && Date.now() - info.mtimeMs > staleLockMs) {
50
+ await unlink(candidate).catch(() => {
51
+ return;
52
+ });
53
+ }
54
+ }
55
+ }
56
+ async function reclaim(lockPath, observed, staleLockMs, abandonedAfterMs) {
57
+ const guardPath = `${lockPath}.reclaim`;
58
+ let guard;
59
+ try {
60
+ guard = await open(guardPath, "wx");
61
+ } catch (error) {
62
+ if (!existing(error))
63
+ throw error;
64
+ const orphan = await stat(guardPath).catch(() => null);
65
+ if (orphan && Date.now() - orphan.mtimeMs > staleLockMs) {
66
+ await unlink(guardPath).catch(() => {
67
+ return;
68
+ });
69
+ }
70
+ return false;
71
+ }
72
+ try {
73
+ const current = await stat(lockPath).catch(() => null);
74
+ if (!current || current.dev !== observed.dev || current.ino !== observed.ino || current.mtimeMs !== observed.mtimeMs) {
75
+ return false;
76
+ }
77
+ const owner = await ownerState(lockPath);
78
+ const age = Date.now() - current.mtimeMs;
79
+ if (owner !== "gone" && age < abandonedAfterMs)
80
+ return false;
81
+ await unlink(lockPath);
82
+ return true;
83
+ } finally {
84
+ await guard.close().catch(() => {
85
+ return;
86
+ });
87
+ await unlink(guardPath).catch(() => {
88
+ return;
89
+ });
90
+ }
91
+ }
92
+ function createFileStateStore(path, options) {
93
+ const lockPath = `${path}.lock`;
94
+ const lockTimeoutMs = options.lockTimeoutMs ?? 1e4;
95
+ const staleLockMs = options.staleLockMs ?? 3000;
96
+ const retryMs = options.retryMs ?? 10;
97
+ const heartbeatMs = Math.max(MIN_HEARTBEAT_MS, Math.floor(staleLockMs / 3));
98
+ const abandonedAfterMs = Math.max(staleLockMs * ABANDONED_LOCK_MULTIPLIER, heartbeatMs * ABANDONED_HEARTBEATS);
99
+ if (staleLockMs >= lockTimeoutMs) {
100
+ throw new Error(`[stitchkit] createFileStateStore: staleLockMs (${staleLockMs}) must be below lockTimeoutMs (${lockTimeoutMs}), or a crashed holder blocks every update until the timeout`);
101
+ }
102
+ let sweptTemporaries = false;
103
+ const parseFile = async () => {
104
+ let source;
105
+ try {
106
+ source = await readFile(path, "utf8");
107
+ } catch (error) {
108
+ if (await missing(error))
109
+ return null;
110
+ throw error;
111
+ }
112
+ try {
113
+ return options.schema.parse(JSON.parse(source));
114
+ } catch (error) {
115
+ await options.onCorrupt?.({ path, error });
116
+ if ((options.corrupt ?? "throw") === "empty")
117
+ return null;
118
+ throw error;
119
+ }
120
+ };
121
+ const acquire = async () => {
122
+ await mkdir(dirname(path), { recursive: true });
123
+ if (!sweptTemporaries) {
124
+ sweptTemporaries = true;
125
+ await sweepTemporaries(path, staleLockMs);
126
+ }
127
+ const deadline = Date.now() + lockTimeoutMs;
128
+ while (true) {
129
+ try {
130
+ const handle = await open(lockPath, "wx");
131
+ const token = crypto.randomUUID();
132
+ try {
133
+ await handle.writeFile(JSON.stringify({ token, pid: process.pid, acquiredAt: Date.now() }));
134
+ await handle.sync();
135
+ } catch (error) {
136
+ await handle.close().catch(() => {
137
+ return;
138
+ });
139
+ await unlink(lockPath).catch(() => {
140
+ return;
141
+ });
142
+ throw error;
143
+ }
144
+ return {
145
+ refresh: async () => {
146
+ const now = new Date;
147
+ await handle.utimes(now, now);
148
+ },
149
+ release: async () => {
150
+ await handle.close();
151
+ try {
152
+ const owner = JSON.parse(await readFile(lockPath, "utf8"));
153
+ if (isRecord(owner) && owner.token === token)
154
+ await unlink(lockPath);
155
+ } catch (error) {
156
+ if (!await missing(error))
157
+ throw error;
158
+ }
159
+ }
160
+ };
161
+ } catch (error) {
162
+ if (!existing(error))
163
+ throw error;
164
+ try {
165
+ const lock = await stat(lockPath);
166
+ if (Date.now() - lock.mtimeMs > staleLockMs) {
167
+ if (await reclaim(lockPath, lock, staleLockMs, abandonedAfterMs))
168
+ continue;
169
+ }
170
+ } catch (statError) {
171
+ if (await missing(statError))
172
+ continue;
173
+ throw statError;
174
+ }
175
+ if (Date.now() >= deadline) {
176
+ throw new Error(`[stitchkit] timed out acquiring state lock for ${path}`);
177
+ }
178
+ await delay(retryMs);
179
+ }
180
+ }
181
+ };
182
+ const write = async (state) => {
183
+ const validated = options.schema.parse(state);
184
+ const temporary = `${path}.tmp.${process.pid}.${crypto.randomUUID()}`;
185
+ const handle = await open(temporary, "wx");
186
+ try {
187
+ try {
188
+ await handle.writeFile(`${JSON.stringify(validated)}
189
+ `);
190
+ await handle.sync();
191
+ } finally {
192
+ await handle.close();
193
+ }
194
+ await rename(temporary, path);
195
+ } catch (error) {
196
+ await unlink(temporary).catch(() => {
197
+ return;
198
+ });
199
+ throw error;
200
+ }
201
+ };
202
+ return {
203
+ read: parseFile,
204
+ async update(transition) {
205
+ const lock = await acquire();
206
+ const heartbeat = setInterval(() => void lock.refresh().catch(() => {
207
+ return;
208
+ }), heartbeatMs);
209
+ try {
210
+ const next = await transition(await parseFile());
211
+ await write(next.state);
212
+ return next.result;
213
+ } finally {
214
+ clearInterval(heartbeat);
215
+ await lock.release();
216
+ }
217
+ }
218
+ };
219
+ }
220
+
221
+ export { createFileStateStore };
@@ -0,0 +1,4 @@
1
+ /** The phases of a live voice conversation, one vocabulary for every side of it. */
2
+ export declare const LIVE_VOICE_PHASES: readonly ['opening', 'idle', 'hearing', 'thinking', 'speaking'];
3
+ export type LiveVoicePhase = (typeof LIVE_VOICE_PHASES)[number];
4
+ //# sourceMappingURL=phases.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phases.d.ts","sourceRoot":"","sources":["../../src/voice/phases.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,YAC5B,SAAS,EACT,MAAM,EACN,SAAS,EACT,UAAU,EACV,UAAU,CACF,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,8 @@
1
+ export declare class SentenceCutter {
2
+ private said;
3
+ /** The sentences finished since the previous call; with `final`, the rest of the turn too. */
4
+ next(text: string, final: boolean): string[];
5
+ /** Start a new turn. */
6
+ reset(): void;
7
+ }
8
+ //# sourceMappingURL=sentences.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sentences.d.ts","sourceRoot":"","sources":["../../src/voice/sentences.ts"],"names":[],"mappings":"AAkBA,qBAAa,cAAc;IACzB,OAAO,CAAC,IAAI,CAAK;IAEjB,8FAA8F;IAC9F,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAmB3C;IAED,wBAAwB;IACxB,KAAK,IAAI,IAAI,CAEZ;CACF"}
@@ -0,0 +1,2 @@
1
+ export declare function speakableText(markdown: string): string;
2
+ //# sourceMappingURL=speakable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"speakable.d.ts","sourceRoot":"","sources":["../../src/voice/speakable.ts"],"names":[],"mappings":"AAiBA,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAsBtD"}
@@ -0,0 +1,42 @@
1
+ export interface SpeechQueueOptions<Prepared> {
2
+ /** Synthesise one sentence. Aborted when the queue is cancelled. */
3
+ prepare: (text: string, signal: AbortSignal) => Promise<Prepared>;
4
+ /** Play one prepared sentence to its end. Aborted when the queue is cancelled. */
5
+ play: (prepared: Prepared, signal: AbortSignal) => Promise<void>;
6
+ /** Sentences prepared ahead of the one playing. Default 1; 0 prepares only after playing. */
7
+ lookahead?: number;
8
+ /** A sentence that failed to prepare or play is skipped; the queue goes on. */
9
+ onError?: (error: unknown, text: string) => void;
10
+ /** A prepared sentence that will never play — cancelled — to release what it holds. */
11
+ discard?: (prepared: Prepared) => void;
12
+ }
13
+ export declare class SpeechQueue<Prepared> {
14
+ private readonly options;
15
+ readonly done: Promise<void>;
16
+ private readonly lookahead;
17
+ private readonly pending;
18
+ private readonly ahead;
19
+ private current;
20
+ private playing;
21
+ private closed;
22
+ private cancelled;
23
+ private wake;
24
+ private stop;
25
+ private readonly stopped;
26
+ constructor(options: SpeechQueueOptions<Prepared>);
27
+ get isCancelled(): boolean;
28
+ get isSpeaking(): boolean;
29
+ /** Add one sentence. Blank text and anything after `close` or `cancel` is ignored. */
30
+ push(text: string): void;
31
+ /** The turn is over: say what is queued, then finish. */
32
+ close(): void;
33
+ /** Interrupted: abort the sentence playing and every synthesis, drop the rest. */
34
+ cancel(): void;
35
+ private notify;
36
+ /** Start synthesis up to the lookahead: the sentence playing or next, plus `lookahead`. */
37
+ private fill;
38
+ private run;
39
+ private report;
40
+ private release;
41
+ }
42
+ //# sourceMappingURL=speech-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"speech-queue.d.ts","sourceRoot":"","sources":["../../src/voice/speech-queue.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,kBAAkB,CAAC,QAAQ;IAC1C,oEAAoE;IACpE,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClE,kFAAkF;IAClF,IAAI,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,6FAA6F;IAC7F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,uFAAuF;IACvF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;CACxC;AAqBD,qBAAa,WAAW,CAAC,QAAQ;IAenB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAdpC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAwB;IAC9C,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,IAAI,CAA6B;IACzC,OAAO,CAAC,IAAI,CAAqC;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAErB;IAEH,YAA6B,OAAO,EAAE,kBAAkB,CAAC,QAAQ,CAAC,EAOjE;IAED,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,sFAAsF;IACtF,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAKvB;IAED,yDAAyD;IACzD,KAAK,IAAI,IAAI,CAGZ;IAED,kFAAkF;IAClF,MAAM,IAAI,IAAI,CAgBb;IAED,OAAO,CAAC,MAAM;IAId,2FAA2F;IAC3F,OAAO,CAAC,IAAI;YAaE,GAAG;IAmCjB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,OAAO;CAOhB"}
@@ -0,0 +1,9 @@
1
+ # stitchkit/application/directory-inbox — stitchkit docs slice
2
+
3
+ > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
+ > `llms.txt` in this package.
5
+
6
+ Contains: pointers only
7
+
8
+ Also read:
9
+ - see `llms/application.txt` for the Managed application kernel guide
@@ -299,6 +299,8 @@ cutovers are covered by the executable
299
299
  | `NotificationOutboxItem` / `NotificationOutboxReceipt` / `NotificationOutboxState` / `NotificationSend` / `NotificationFailureClassification` / `DroppedNotification` / `EnqueueNotification` | _type_ | versioned queue records, stable send identity and delivery decisions |
300
300
  | `notificationOutboxStateSchema` | function | strict persisted-state schema over an application payload schema |
301
301
  | `notificationOutboxResource` / `NotificationOutboxResource` / `NotificationOutboxResourceConfig` | function / _type_ | thin managed-resource scheduler over an outbox handle |
302
+ | `createDirectoryInbox` (from `stitchkit/application/directory-inbox`) / `DirectoryInbox` / `DirectoryInboxConfig` / `DirectoryInboxDelivery` / `DirectoryInboxResource` | function / _type_ | managed resource delivering each `<name>.json` entry another program drops into a directory to the application at least once, after readiness; separate durable taken/done records, leases, bounded retry |
303
+ | `DirectoryInboxStateSchema` / `DirectoryInboxState` / `DirectoryInboxRejection` / `DirectoryInboxRejectionReasonSchema` / `DirectoryInboxRejectionReason` | schema / _type_ | the inbox's claims, receipts and rejections (`invalid`, `too-large`, `attempt-limit`) |
302
304
  | `ApplicationHandle.restart` | method | replace one resource and everything that depends on it, leaving the rest of the graph running and the process epoch unchanged |
303
305
  | `ApplicationRestartInputSchema` / `ApplicationRestartInput` | schema / _type_ | the resource to replace, by id, and optionally `gracePeriodMs` / `forceTimeoutMs` for this restart — the application's own shutdown budget otherwise |
304
306
  | `ApplicationRestartResultSchema` / `ApplicationRestartResult` | schema / _type_ | the subtree that was actually taken down and brought back, in start order, with the outcome, the reason on anything but success, and how long it took |
@@ -905,7 +905,36 @@ deadline holds `stop()` — and so the resource's `force()` — for that one cal
905
905
  so give the transport a timeout. Superseding a key that is being sent right now
906
906
  does not recall it: the send completes, and only its receipt is not written.
907
907
 
908
- Both primitives depend on the structural `StateStore`. On a server,
908
+ `createDirectoryInbox` is the receiving side for another program on the same
909
+ host: it drops entries — a release transition, say — into a directory, and
910
+ the application takes each one at least once. It is a managed resource of its
911
+ own (`createDirectoryInbox({ id, directory, schema, handle })`, imported from
912
+ `stitchkit/application/directory-inbox` because it touches files; its types
913
+ and schemas are in `stitchkit/application`) and publishes a handle with
914
+ `flush()` and `state()`. The producer writes each entry
915
+ atomically as `<name>.json` — a temporary name, then a rename; names that start
916
+ with a dot are never read. Entries are delivered in name order, and only after
917
+ `activate`: none reaches the application before it is ready. `stopAdmission`
918
+ stops taking new ones, `drain` waits for the one in flight, and `force` aborts
919
+ the `signal` its handler was given.
920
+
921
+ Taken and done are separate durable records. Before `handle` runs, the entry
922
+ is claimed with a lease (`leaseMs`, default five minutes); after it returns,
923
+ a receipt replaces the claim and only then is the file removed. A process that
924
+ died mid-delivery leaves a claim, and the entry is taken again once the lease
925
+ runs out — at least once, not exactly once, so a handler that sends somewhere
926
+ should carry the entry's `key` as its idempotency key. A process that died
927
+ after the receipt leaves only the file, which the next pass removes without
928
+ delivering it again. A handler that throws is retried after a backoff from one
929
+ second doubling to five minutes; an entry that fails `schema`, is over
930
+ `maxEntryBytes` or was taken `maxAttempts` times (default 20 — the count
931
+ survives restarts, so an entry whose handling kills the process is not taken
932
+ forever) is moved to `rejected/` with its reason and reported to `onRejected`,
933
+ and the entries after it keep flowing. The state lives in
934
+ `<directory>/.inbox-state.json` under `createFileStateStore`'s inter-process
935
+ lock unless a `store` is supplied.
936
+
937
+ These primitives depend on the structural `StateStore`. On a server,
909
938
  `createFileStateStore` supplies the shared Zod-validated JSON adapter with an
910
939
  inter-process lock, unique temporary file, fsync and atomic rename. The lock
911
940
  is a file with a heartbeat: the holder refreshes its mtime every third of
@@ -73,6 +73,7 @@ own, recorded as an ADR.
73
73
  | `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
74
74
  | `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
75
75
  | `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
76
+ | `stitchkit/voice` | browser **and** server | evolving | the mechanics of a live voice reply — `SentenceCutter`, `speakableText`, a `SpeechQueue` that synthesises ahead and stops on interruption, the conversation phases |
76
77
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
77
78
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
78
79
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
@@ -95,6 +96,7 @@ own, recorded as an ADR.
95
96
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
96
97
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
97
98
  | `stitchkit/application/diagnostic-journal` | server | evolving | the local diagnostic journal — the one part of the kernel that spawns, locks and writes files |
99
+ | `stitchkit/application/directory-inbox` | server | evolving | the directory inbox — entries another program drops into a directory, delivered to the application at least once |
98
100
 
99
101
  Rule of thumb: browser code imports `stitchkit` and `stitchkit/react`; server
100
102
  code adds `stitchkit/server` (or `stitchkit/node` on Node) and opts into
package/llms/voice.txt ADDED
@@ -0,0 +1,106 @@
1
+ # stitchkit/voice — stitchkit docs slice
2
+
3
+ > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
+ > `llms.txt` in this package.
5
+
6
+ Contains: Guide: Voice (docs/guide/voice.md) · API reference: `stitchkit/voice` (docs/api/reference.md)
7
+
8
+ ==============================================================================
9
+ # Guide: Voice (docs/guide/voice.md)
10
+ ==============================================================================
11
+
12
+ # Voice
13
+
14
+ A live voice conversation with an agent repeats the same mechanics in every application
15
+ that has one: the reply is cut into sentences while the model is still writing it, each
16
+ sentence is stripped down to what can be said aloud, and a queue speaks them in order — and
17
+ stops at once when the listener interrupts. `stitchkit/voice` is those mechanics, the same in
18
+ the browser and on Bun or Node, with no dependencies. It is **evolving**.
19
+
20
+ Speech recognition, synthesis providers, the microphone and the player stay with the
21
+ application: the queue takes them as two functions.
22
+
23
+ ## Sentences while the reply streams
24
+
25
+ ```ts
26
+ import { SentenceCutter, SpeechQueue, speakableText } from 'stitchkit/voice'
27
+
28
+ const cutter = new SentenceCutter()
29
+
30
+ for await (const text of reply) { // the whole reply so far, on every chunk
31
+ for (const sentence of cutter.next(text, false)) say(sentence)
32
+ }
33
+ for (const sentence of cutter.next(finalText, true)) say(sentence) // the rest of the turn
34
+ cutter.reset() // the next turn starts from zero
35
+ ```
36
+
37
+ A sentence ends at `. ! ? …` (with any closing quote or bracket) followed by whitespace and a
38
+ capital letter, a digit or an opening mark, or at a blank line between paragraphs. `3.5`,
39
+ `т. е. так` and `e.g. this` are not cut; the end of the text is not the end of a sentence
40
+ until `final`; nothing is cut inside a fenced code block, even one that is still open.
41
+
42
+ ## What is said
43
+
44
+ `speakableText(markdown)` keeps what a reader sees and drops what cannot be said: headings,
45
+ emphasis and list markers lose their delimiters, a link keeps its text and loses its
46
+ address, and images, bare addresses, fenced code blocks and tables are dropped whole. It does
47
+ not summarise. A sentence that was only code becomes `''`, and the queue ignores blank text.
48
+
49
+ ## Speaking in order, synthesising ahead
50
+
51
+ ```ts
52
+ const queue = new SpeechQueue<Blob>({
53
+ prepare: (text, signal) => synthesise(text, { signal }), // the whole clip
54
+ play: (clip, signal) => player.play(clip, { signal }), // resolves when it has played
55
+ lookahead: 1, // the default
56
+ onError: (error, text) => log.warn('sentence skipped', { error, text }),
57
+ discard: (clip) => release(clip), // prepared, never played
58
+ })
59
+
60
+ const say = (sentence: string) => queue.push(speakableText(sentence))
61
+
62
+ // the turn is over: say what is queued, then `done` settles
63
+ queue.close()
64
+ // the listener interrupted: the playing sentence and every synthesis are aborted
65
+ queue.cancel()
66
+ await queue.done
67
+ ```
68
+
69
+ While one sentence plays, up to `lookahead` more are already synthesising, so a synthesiser
70
+ that returns the whole clip does not leave a gap the length of its synthesis between two
71
+ sentences. `lookahead: 0` synthesises each sentence only after the previous one played.
72
+
73
+ A sentence that fails to synthesise or to play is reported to `onError` and skipped; the
74
+ queue goes on. `cancel()` aborts the signal of the sentence playing and of every synthesis in
75
+ flight, drops the rest, calls `discard` for every clip that was prepared and will not play,
76
+ and settles `done` without waiting for a `play` that ignores its signal. `isSpeaking` and
77
+ `isCancelled` say where the queue is.
78
+
79
+ ## Phases
80
+
81
+ ```ts
82
+ import { LIVE_VOICE_PHASES, type LiveVoicePhase } from 'stitchkit/voice'
83
+ // 'opening' | 'idle' | 'hearing' | 'thinking' | 'speaking'
84
+ ```
85
+
86
+ One vocabulary for every side of the conversation — the server that runs the agent and the
87
+ page that shows the microphone — so a phase sent over the wire means the same on both ends.
88
+
89
+ ==============================================================================
90
+ # API reference: `stitchkit/voice` (docs/api/reference.md)
91
+ ==============================================================================
92
+
93
+ ## `stitchkit/voice`
94
+
95
+ Isomorphic, dependency-free mechanics of a live voice conversation. Recognition, synthesis
96
+ providers, the microphone and the player remain application-owned. See the
97
+ [voice guide](../guide/voice.md).
98
+
99
+ | Export | Kind | Summary |
100
+ |--------|------|---------|
101
+ | `SentenceCutter` | class | `next(text, final)` returns the sentences finished since the previous call of the reply streamed so far — never inside a fenced code block, never at `3.5` or `т. е.`; `reset()` starts a new turn |
102
+ | `speakableText` | function | the visible text of Markdown without delimiters, link and image addresses, bare addresses, fenced code blocks or tables |
103
+ | `SpeechQueue` / `SpeechQueueOptions` | class / _type_ | speak sentences in order with `lookahead` (default 1) synthesised while one plays; `close()` finishes the queue, `cancel()` aborts the playing sentence and every synthesis; a failed sentence is reported to `onError` and skipped |
104
+ | `LIVE_VOICE_PHASES` / `LiveVoicePhase` | constant / _type_ | `opening`, `idle`, `hearing`, `thinking`, `speaking` |
105
+
106
+ ---
package/llms-full.txt CHANGED
@@ -64,6 +64,7 @@ own, recorded as an ADR.
64
64
  | `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
65
65
  | `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
66
66
  | `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
67
+ | `stitchkit/voice` | browser **and** server | evolving | the mechanics of a live voice reply — `SentenceCutter`, `speakableText`, a `SpeechQueue` that synthesises ahead and stops on interruption, the conversation phases |
67
68
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
68
69
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
69
70
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
@@ -86,6 +87,7 @@ own, recorded as an ADR.
86
87
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
87
88
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
88
89
  | `stitchkit/application/diagnostic-journal` | server | evolving | the local diagnostic journal — the one part of the kernel that spawns, locks and writes files |
90
+ | `stitchkit/application/directory-inbox` | server | evolving | the directory inbox — entries another program drops into a directory, delivered to the application at least once |
89
91
 
90
92
  Rule of thumb: browser code imports `stitchkit` and `stitchkit/react`; server
91
93
  code adds `stitchkit/server` (or `stitchkit/node` on Node) and opts into
@@ -7151,7 +7153,36 @@ deadline holds `stop()` — and so the resource's `force()` — for that one cal
7151
7153
  so give the transport a timeout. Superseding a key that is being sent right now
7152
7154
  does not recall it: the send completes, and only its receipt is not written.
7153
7155
 
7154
- Both primitives depend on the structural `StateStore`. On a server,
7156
+ `createDirectoryInbox` is the receiving side for another program on the same
7157
+ host: it drops entries — a release transition, say — into a directory, and
7158
+ the application takes each one at least once. It is a managed resource of its
7159
+ own (`createDirectoryInbox({ id, directory, schema, handle })`, imported from
7160
+ `stitchkit/application/directory-inbox` because it touches files; its types
7161
+ and schemas are in `stitchkit/application`) and publishes a handle with
7162
+ `flush()` and `state()`. The producer writes each entry
7163
+ atomically as `<name>.json` — a temporary name, then a rename; names that start
7164
+ with a dot are never read. Entries are delivered in name order, and only after
7165
+ `activate`: none reaches the application before it is ready. `stopAdmission`
7166
+ stops taking new ones, `drain` waits for the one in flight, and `force` aborts
7167
+ the `signal` its handler was given.
7168
+
7169
+ Taken and done are separate durable records. Before `handle` runs, the entry
7170
+ is claimed with a lease (`leaseMs`, default five minutes); after it returns,
7171
+ a receipt replaces the claim and only then is the file removed. A process that
7172
+ died mid-delivery leaves a claim, and the entry is taken again once the lease
7173
+ runs out — at least once, not exactly once, so a handler that sends somewhere
7174
+ should carry the entry's `key` as its idempotency key. A process that died
7175
+ after the receipt leaves only the file, which the next pass removes without
7176
+ delivering it again. A handler that throws is retried after a backoff from one
7177
+ second doubling to five minutes; an entry that fails `schema`, is over
7178
+ `maxEntryBytes` or was taken `maxAttempts` times (default 20 — the count
7179
+ survives restarts, so an entry whose handling kills the process is not taken
7180
+ forever) is moved to `rejected/` with its reason and reported to `onRejected`,
7181
+ and the entries after it keep flowing. The state lives in
7182
+ `<directory>/.inbox-state.json` under `createFileStateStore`'s inter-process
7183
+ lock unless a `store` is supplied.
7184
+
7185
+ These primitives depend on the structural `StateStore`. On a server,
7155
7186
  `createFileStateStore` supplies the shared Zod-validated JSON adapter with an
7156
7187
  inter-process lock, unique temporary file, fsync and atomic rename. The lock
7157
7188
  is a file with a heartbeat: the holder refreshes its mtime every third of
@@ -12603,6 +12634,88 @@ session query with `cache`; the generic starter does not invent a `getSession`
12603
12634
  endpoint or cookie vocabulary.
12604
12635
 
12605
12636
 
12637
+ ==============================================================================
12638
+ # Guide: Voice (docs/guide/voice.md)
12639
+ ==============================================================================
12640
+
12641
+ # Voice
12642
+
12643
+ A live voice conversation with an agent repeats the same mechanics in every application
12644
+ that has one: the reply is cut into sentences while the model is still writing it, each
12645
+ sentence is stripped down to what can be said aloud, and a queue speaks them in order — and
12646
+ stops at once when the listener interrupts. `stitchkit/voice` is those mechanics, the same in
12647
+ the browser and on Bun or Node, with no dependencies. It is **evolving**.
12648
+
12649
+ Speech recognition, synthesis providers, the microphone and the player stay with the
12650
+ application: the queue takes them as two functions.
12651
+
12652
+ ## Sentences while the reply streams
12653
+
12654
+ ```ts
12655
+ import { SentenceCutter, SpeechQueue, speakableText } from 'stitchkit/voice'
12656
+
12657
+ const cutter = new SentenceCutter()
12658
+
12659
+ for await (const text of reply) { // the whole reply so far, on every chunk
12660
+ for (const sentence of cutter.next(text, false)) say(sentence)
12661
+ }
12662
+ for (const sentence of cutter.next(finalText, true)) say(sentence) // the rest of the turn
12663
+ cutter.reset() // the next turn starts from zero
12664
+ ```
12665
+
12666
+ A sentence ends at `. ! ? …` (with any closing quote or bracket) followed by whitespace and a
12667
+ capital letter, a digit or an opening mark, or at a blank line between paragraphs. `3.5`,
12668
+ `т. е. так` and `e.g. this` are not cut; the end of the text is not the end of a sentence
12669
+ until `final`; nothing is cut inside a fenced code block, even one that is still open.
12670
+
12671
+ ## What is said
12672
+
12673
+ `speakableText(markdown)` keeps what a reader sees and drops what cannot be said: headings,
12674
+ emphasis and list markers lose their delimiters, a link keeps its text and loses its
12675
+ address, and images, bare addresses, fenced code blocks and tables are dropped whole. It does
12676
+ not summarise. A sentence that was only code becomes `''`, and the queue ignores blank text.
12677
+
12678
+ ## Speaking in order, synthesising ahead
12679
+
12680
+ ```ts
12681
+ const queue = new SpeechQueue<Blob>({
12682
+ prepare: (text, signal) => synthesise(text, { signal }), // the whole clip
12683
+ play: (clip, signal) => player.play(clip, { signal }), // resolves when it has played
12684
+ lookahead: 1, // the default
12685
+ onError: (error, text) => log.warn('sentence skipped', { error, text }),
12686
+ discard: (clip) => release(clip), // prepared, never played
12687
+ })
12688
+
12689
+ const say = (sentence: string) => queue.push(speakableText(sentence))
12690
+
12691
+ // the turn is over: say what is queued, then `done` settles
12692
+ queue.close()
12693
+ // the listener interrupted: the playing sentence and every synthesis are aborted
12694
+ queue.cancel()
12695
+ await queue.done
12696
+ ```
12697
+
12698
+ While one sentence plays, up to `lookahead` more are already synthesising, so a synthesiser
12699
+ that returns the whole clip does not leave a gap the length of its synthesis between two
12700
+ sentences. `lookahead: 0` synthesises each sentence only after the previous one played.
12701
+
12702
+ A sentence that fails to synthesise or to play is reported to `onError` and skipped; the
12703
+ queue goes on. `cancel()` aborts the signal of the sentence playing and of every synthesis in
12704
+ flight, drops the rest, calls `discard` for every clip that was prepared and will not play,
12705
+ and settles `done` without waiting for a `play` that ignores its signal. `isSpeaking` and
12706
+ `isCancelled` say where the queue is.
12707
+
12708
+ ## Phases
12709
+
12710
+ ```ts
12711
+ import { LIVE_VOICE_PHASES, type LiveVoicePhase } from 'stitchkit/voice'
12712
+ // 'opening' | 'idle' | 'hearing' | 'thinking' | 'speaking'
12713
+ ```
12714
+
12715
+ One vocabulary for every side of the conversation — the server that runs the agent and the
12716
+ page that shows the microphone — so a phase sent over the wire means the same on both ends.
12717
+
12718
+
12606
12719
  ==============================================================================
12607
12720
  # Guide: GeoIP (docs/guide/geo.md)
12608
12721
  ==============================================================================
@@ -16978,6 +17091,8 @@ cutovers are covered by the executable
16978
17091
  | `NotificationOutboxItem` / `NotificationOutboxReceipt` / `NotificationOutboxState` / `NotificationSend` / `NotificationFailureClassification` / `DroppedNotification` / `EnqueueNotification` | _type_ | versioned queue records, stable send identity and delivery decisions |
16979
17092
  | `notificationOutboxStateSchema` | function | strict persisted-state schema over an application payload schema |
16980
17093
  | `notificationOutboxResource` / `NotificationOutboxResource` / `NotificationOutboxResourceConfig` | function / _type_ | thin managed-resource scheduler over an outbox handle |
17094
+ | `createDirectoryInbox` (from `stitchkit/application/directory-inbox`) / `DirectoryInbox` / `DirectoryInboxConfig` / `DirectoryInboxDelivery` / `DirectoryInboxResource` | function / _type_ | managed resource delivering each `<name>.json` entry another program drops into a directory to the application at least once, after readiness; separate durable taken/done records, leases, bounded retry |
17095
+ | `DirectoryInboxStateSchema` / `DirectoryInboxState` / `DirectoryInboxRejection` / `DirectoryInboxRejectionReasonSchema` / `DirectoryInboxRejectionReason` | schema / _type_ | the inbox's claims, receipts and rejections (`invalid`, `too-large`, `attempt-limit`) |
16981
17096
  | `ApplicationHandle.restart` | method | replace one resource and everything that depends on it, leaving the rest of the graph running and the process epoch unchanged |
16982
17097
  | `ApplicationRestartInputSchema` / `ApplicationRestartInput` | schema / _type_ | the resource to replace, by id, and optionally `gracePeriodMs` / `forceTimeoutMs` for this restart — the application's own shutdown budget otherwise |
16983
17098
  | `ApplicationRestartResultSchema` / `ApplicationRestartResult` | schema / _type_ | the subtree that was actually taken down and brought back, in start order, with the outcome, the reason on anything but success, and how long it took |
@@ -18268,6 +18383,21 @@ signal stay with the application. → ADR 0167 — [guide](../guide/release.md)
18268
18383
 
18269
18384
  ---
18270
18385
 
18386
+ ## `stitchkit/voice`
18387
+
18388
+ Isomorphic, dependency-free mechanics of a live voice conversation. Recognition, synthesis
18389
+ providers, the microphone and the player remain application-owned. See the
18390
+ [voice guide](../guide/voice.md).
18391
+
18392
+ | Export | Kind | Summary |
18393
+ |--------|------|---------|
18394
+ | `SentenceCutter` | class | `next(text, final)` returns the sentences finished since the previous call of the reply streamed so far — never inside a fenced code block, never at `3.5` or `т. е.`; `reset()` starts a new turn |
18395
+ | `speakableText` | function | the visible text of Markdown without delimiters, link and image addresses, bare addresses, fenced code blocks or tables |
18396
+ | `SpeechQueue` / `SpeechQueueOptions` | class / _type_ | speak sentences in order with `lookahead` (default 1) synthesised while one plays; `close()` finishes the queue, `cancel()` aborts the playing sentence and every synthesis; a failed sentence is reported to `onError` and skipped |
18397
+ | `LIVE_VOICE_PHASES` / `LiveVoicePhase` | constant / _type_ | `opening`, `idle`, `hearing`, `thinking`, `speaking` |
18398
+
18399
+ ---
18400
+
18271
18401
  ## `stitchkit/geo`
18272
18402
 
18273
18403
  Server-only managed GeoIP generations. Applications own database acquisition,