stitchkit 0.95.1 → 0.95.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -15,6 +15,51 @@ additive**; the first breaking change landed in 0.10.0. Grep the file for
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
+ ## [0.95.3] — 2026-09-23
19
+
20
+ ### Fixed
21
+
22
+ - `stitchkit/live` — the watch client took a revision as a floor across two
23
+ lives of the hub. Revisions count one hub's life of a key, so after an API
24
+ restart the new hub answered the reopen with a `full` value at revision 1
25
+ and the client dropped it, and every later one, as "no newer" until the new
26
+ counter passed the old — while showing the key live. The first answer to
27
+ each `open` is now taken whatever its revision; within one open the
28
+ counter orders frames as before.
29
+ - `stitchkit/agent-runtime` — a turn total no longer knows more than its
30
+ steps. `mergeModelTotals` took each field from the SDK aggregate, which the
31
+ OpenRouter adapter had already summed with `cached_tokens ?? 0`, so a turn
32
+ whose every request reported no cache figure ended as
33
+ `{ value: 0, provenance: 'computed' }`. A field every step reported as
34
+ `unavailable` now stays `unavailable` in the total; an explicit zero and a
35
+ positive count are kept.
36
+
37
+ ## [0.95.2] — 2026-09-23
38
+
39
+ ### Added
40
+
41
+ - `stitchkit/voice` (new, browser **and** server, evolving, no dependencies) — the
42
+ mechanics of a live voice conversation with an agent that applications were each
43
+ carrying a copy of:
44
+ - **`SentenceCutter`** — `next(text, final)` cuts a reply into finished sentences
45
+ while it streams, so a voice starts on the first one; not at `3.5`, `т. е.` or an
46
+ ellipsis before lowercase, never inside a fenced code block; `reset()` starts a
47
+ new turn.
48
+ - **`speakableText(markdown)`** — what a reader sees, without Markdown delimiters,
49
+ link and image addresses, bare addresses, fenced code blocks or tables.
50
+ - **`SpeechQueue<Prepared>({ prepare, play, lookahead, onError, discard })`** —
51
+ speaks sentences in order and synthesises up to `lookahead` (default 1) while one
52
+ plays, so a synthesiser that returns whole clips leaves no gap between sentences;
53
+ `close()` finishes what is queued, `cancel()` aborts the playing sentence and every
54
+ synthesis in flight and settles `done` at once; a failed sentence is reported and
55
+ skipped.
56
+ - **`LIVE_VOICE_PHASES` / `LiveVoicePhase`** — `opening`, `idle`, `hearing`,
57
+ `thinking`, `speaking`.
58
+
59
+ Migration for an application with its own copy: import these four from
60
+ `stitchkit/voice` and delete the copy. A sequential queue over a streaming
61
+ synthesiser is `lookahead: 0`.
62
+
18
63
  ## [0.95.1] — 2026-09-23
19
64
 
20
65
  ### Added
@@ -60,6 +60,12 @@ export declare function addUsage(total: AgentUsage | undefined, step: AgentUsage
60
60
  * handed us. Which loop did the adding is not a difference a caller filtering
61
61
  * for a billable figure cares about, and pretending otherwise made one run
62
62
  * report `provider-reported` on success and `computed` on abort.
63
+ *
64
+ * A field every step reported as `unavailable` stays `unavailable`: the
65
+ * aggregate is summed from the SDK's normalized usage, where an adapter may
66
+ * already have written `cached_tokens ?? 0`, so its zero is the adapter's word
67
+ * and not the provider's. The OpenRouter adapter did exactly that, and a turn
68
+ * whose every request said "no cache figure" ended as a confident "cache 0".
63
69
  */
64
70
  export declare function mergeModelTotals(sdkTotal: AgentUsage, accumulated: AgentUsage | undefined): AgentUsage;
65
71
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-internals.d.ts","sourceRoot":"","sources":["../../src/agent-runtime/runtime-internals.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,IAAI,CAAC;AAC7C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAEL,KAAK,gBAAgB,EAGrB,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,UAAU,EAChB,MAAM,WAAW,CAAC;AAGnB;;;;;GAKG;AAEH,wBAAgB,OAAO,CAAC,IAAI,EAAE,SAAS,QAAQ,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,CAI1E;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAG5E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO;;;;cAI9C;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS;;;EAKhF;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,gBAAgB,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAQxE;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG,mBAAmB,CAK5E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,EAAE,IAAI,EAAE,UAAU,GAAG,UAAU,CAuDpF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,UAAU,EACpB,WAAW,EAAE,UAAU,GAAG,SAAS,GAClC,UAAU,CAiBZ;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,UAAU,CAYrE;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,IAAI,UAAU,CAUzC;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CAmBvE;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS;;iBAEhE,IAAI;;;;;;EAgDxB"}
1
+ {"version":3,"file":"runtime-internals.d.ts","sourceRoot":"","sources":["../../src/agent-runtime/runtime-internals.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,IAAI,CAAC;AAC7C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAEL,KAAK,gBAAgB,EAGrB,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,UAAU,EAChB,MAAM,WAAW,CAAC;AAGnB;;;;;GAKG;AAEH,wBAAgB,OAAO,CAAC,IAAI,EAAE,SAAS,QAAQ,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,CAI1E;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAG5E;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO;;;;cAI9C;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS;;;EAKhF;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,gBAAgB,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAQxE;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG,mBAAmB,CAK5E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,EAAE,IAAI,EAAE,UAAU,GAAG,UAAU,CAuDpF;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,UAAU,EACpB,WAAW,EAAE,UAAU,GAAG,SAAS,GAClC,UAAU,CAmBZ;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,UAAU,CAYrE;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,IAAI,UAAU,CAUzC;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CAmBvE;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS;;iBAEhE,IAAI;;;;;;EAgDxB"}
@@ -6,7 +6,7 @@ import {
6
6
  composeAgentPrompt,
7
7
  createAgentRuntime,
8
8
  createAgentRuntimeEventSink
9
- } from "../../index-8zja4pkf.js";
9
+ } from "../../index-nts4a2br.js";
10
10
  import"../../index-83bdg97s.js";
11
11
  import"../../index-y64k3s0h.js";
12
12
  import {
@@ -24,7 +24,7 @@ import {
24
24
  renderAgentStateSlots,
25
25
  repairedSearchCall,
26
26
  selectAgentHistory
27
- } from "../index-8zja4pkf.js";
27
+ } from "../index-nts4a2br.js";
28
28
  import"../index-83bdg97s.js";
29
29
  import {
30
30
  AgentAdmissionEventSchema,
@@ -86,6 +86,7 @@ async function openWatch(config, openTimeoutMs, entry) {
86
86
  if (entry.opened)
87
87
  return;
88
88
  entry.opened = true;
89
+ entry.answered = false;
89
90
  try {
90
91
  const have = entry.hasValue && entry.fingerprint !== undefined ? { revision: entry.revision, fingerprint: entry.fingerprint } : undefined;
91
92
  const acknowledgement = await config.transport.request(WATCH_OPEN, { key: entry.key, args: entry.args, ...have !== undefined && { have } }, { timeoutMs: openTimeoutMs });
@@ -121,10 +122,12 @@ function applyWatchFrame(entry, frame) {
121
122
  }
122
123
  entry.revision = frame.revision;
123
124
  entry.fingerprint = frame.fingerprint;
125
+ entry.answered = true;
124
126
  return { kind: "confirmed" };
125
127
  }
126
- if (entry.hasValue && frame.revision <= entry.revision)
128
+ if (entry.hasValue && entry.answered && frame.revision <= entry.revision) {
127
129
  return { kind: "stale" };
130
+ }
128
131
  let value;
129
132
  if (frame.kind === "full") {
130
133
  value = frame.value;
@@ -154,6 +157,7 @@ function applyWatchFrame(entry, frame) {
154
157
  entry.value = value;
155
158
  entry.fingerprint = frame.fingerprint;
156
159
  entry.hasValue = true;
160
+ entry.answered = true;
157
161
  return { kind: "value", value };
158
162
  }
159
163
  function createWatchClient(contract, config) {
@@ -226,6 +230,7 @@ function createWatchClient(contract, config) {
226
230
  revision: 0,
227
231
  hasValue: false,
228
232
  opened: false,
233
+ answered: false,
229
234
  state: { key, phase: "opening" }
230
235
  };
231
236
  entries.set(id, entry);
@@ -0,0 +1,6 @@
1
+ /** Isomorphic primitives of a live voice conversation: sentences, speakable text, speech queue. */
2
+ export { LIVE_VOICE_PHASES, type LiveVoicePhase } from '../voice/phases.js';
3
+ export { SentenceCutter } from '../voice/sentences.js';
4
+ export { speakableText } from '../voice/speakable.js';
5
+ export { SpeechQueue, type SpeechQueueOptions } from '../voice/speech-queue.js';
6
+ //# sourceMappingURL=voice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../../src/entrypoints/voice.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,OAAO,EAAE,iBAAiB,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACzE,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,uBAAuB,CAAC"}
@@ -0,0 +1,221 @@
1
+ // src/voice/phases.ts
2
+ var LIVE_VOICE_PHASES = [
3
+ "opening",
4
+ "idle",
5
+ "hearing",
6
+ "thinking",
7
+ "speaking"
8
+ ];
9
+ // src/voice/sentences.ts
10
+ var TERMINAL = /[.!?…]/;
11
+ var CLOSING = /["»”)]/;
12
+ var ENDS_SENTENCE = /[.!?…]+["»”)]*$/;
13
+ var OPENER = /^[\p{Lu}\p{N}"«“(—–-]/u;
14
+ var NEXT_WORD = /^\s+(\S)/u;
15
+
16
+ class SentenceCutter {
17
+ said = 0;
18
+ next(text, final) {
19
+ const sentences = [];
20
+ let start = this.said;
21
+ let fence = fenceFrom(text, this.said);
22
+ for (let index = this.said;index < text.length; index += 1) {
23
+ if (index >= fence.end)
24
+ fence = fenceFrom(text, index);
25
+ if (index >= fence.start && index < fence.end)
26
+ continue;
27
+ if (!isBoundary(text, index, start))
28
+ continue;
29
+ const sentence = text.slice(start, index + 1).trim();
30
+ if (sentence !== "")
31
+ sentences.push(sentence);
32
+ start = index + 1;
33
+ }
34
+ this.said = start;
35
+ if (final) {
36
+ const rest = text.slice(start).trim();
37
+ if (rest !== "")
38
+ sentences.push(rest);
39
+ this.said = text.length;
40
+ }
41
+ return sentences;
42
+ }
43
+ reset() {
44
+ this.said = 0;
45
+ }
46
+ }
47
+ function isBoundary(text, index, start) {
48
+ const char = text[index] ?? "";
49
+ if (char === `
50
+ ` && text[index + 1] === `
51
+ `)
52
+ return true;
53
+ if (!TERMINAL.test(char) && !CLOSING.test(char))
54
+ return false;
55
+ const following = text[index + 1] ?? "";
56
+ if (TERMINAL.test(following) || CLOSING.test(following))
57
+ return false;
58
+ if (!ENDS_SENTENCE.test(text.slice(start, index + 1)))
59
+ return false;
60
+ const next = text.slice(index + 1, index + 64).match(NEXT_WORD);
61
+ return next !== null && OPENER.test(next[1] ?? "");
62
+ }
63
+ function fenceFrom(text, from) {
64
+ const open = text.indexOf("```", from);
65
+ if (open === -1)
66
+ return { start: text.length, end: text.length };
67
+ const close = text.indexOf("```", open + 3);
68
+ return { start: open, end: close === -1 ? text.length : close + 3 };
69
+ }
70
+ // src/voice/speakable.ts
71
+ var FENCED_BLOCK = /^[ \t]*(```|~~~)[^\n]*\n[\s\S]*?(?:^[ \t]*\1[^\n]*$|(?![\s\S]))/gm;
72
+ var TABLE_ROW = /^[ \t]*\|.*$/gm;
73
+ var THEMATIC_BREAK = /^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$/gm;
74
+ var IMAGE = /!\[[^\]]*\]\((?:<[^>]*>|[^)])*\)/g;
75
+ var LINK = /\[([^\]]+)\]\((?:<[^>]*>|[^)])*\)/g;
76
+ var REFERENCE_LINK = /\[([^\]]+)\]\[[^\]]*\]/g;
77
+ var LINK_DEFINITION = /^[ \t]{0,3}\[[^\]]+\]:[ \t]*\S+.*$/gm;
78
+ var AUTOLINK = /<(?:https?|mailto):[^>\s]*>/g;
79
+ var BARE_URL = /\bhttps?:\/\/[^\s<>()]+/g;
80
+ var HTML_TAG = /<\/?[A-Za-z][^>]*>/g;
81
+ var LINE_MARKER = /^[ \t]{0,3}(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+(?:\[[ xX]\][ \t]+)?)/gm;
82
+ function speakableText(markdown) {
83
+ return markdown.replace(FENCED_BLOCK, " ").replace(TABLE_ROW, " ").replace(THEMATIC_BREAK, " ").replace(LINK_DEFINITION, " ").replace(IMAGE, "").replace(LINK, "$1").replace(REFERENCE_LINK, "$1").replace(AUTOLINK, "").replace(BARE_URL, "").replace(HTML_TAG, "").replace(LINE_MARKER, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/~~([^~]+)~~/g, "$1").replace(/(^|[^\p{L}\p{N}*])\*([^*\s][^*]*?)\*(?![\p{L}\p{N}*])/gu, "$1$2").replace(/(^|[^\p{L}\p{N}_])_([^_\s][^_]*?)_(?![\p{L}\p{N}_])/gu, "$1$2").replace(/`([^`]+)`/g, "$1").replace(/\s+/g, " ").replace(/ ([.,!?;:…])/g, "$1").trim();
84
+ }
85
+ // src/voice/speech-queue.ts
86
+ var CANCELLED = Symbol("cancelled");
87
+ function settle(run) {
88
+ return Promise.resolve().then(run).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
89
+ }
90
+
91
+ class SpeechQueue {
92
+ options;
93
+ done;
94
+ lookahead;
95
+ pending = [];
96
+ ahead = [];
97
+ current = null;
98
+ playing = false;
99
+ closed = false;
100
+ cancelled = false;
101
+ wake = null;
102
+ stop;
103
+ stopped = new Promise((resolve) => {
104
+ this.stop = resolve;
105
+ });
106
+ constructor(options) {
107
+ this.options = options;
108
+ const lookahead = options.lookahead ?? 1;
109
+ if (!Number.isInteger(lookahead) || lookahead < 0) {
110
+ throw new RangeError(`lookahead must be a non-negative integer, got ${lookahead}`);
111
+ }
112
+ this.lookahead = lookahead;
113
+ this.done = this.run();
114
+ }
115
+ get isCancelled() {
116
+ return this.cancelled;
117
+ }
118
+ get isSpeaking() {
119
+ return this.playing;
120
+ }
121
+ push(text) {
122
+ if (this.closed || this.cancelled || text.trim() === "")
123
+ return;
124
+ this.pending.push(text);
125
+ this.fill();
126
+ this.notify();
127
+ }
128
+ close() {
129
+ this.closed = true;
130
+ this.notify();
131
+ }
132
+ cancel() {
133
+ if (this.cancelled)
134
+ return;
135
+ this.cancelled = true;
136
+ this.closed = true;
137
+ this.pending.length = 0;
138
+ const unplayed = this.ahead.splice(0);
139
+ if (this.current && !this.playing)
140
+ unplayed.push(this.current);
141
+ this.current?.controller.abort();
142
+ for (const slot of unplayed) {
143
+ slot.controller.abort();
144
+ slot.result.then((outcome) => {
145
+ if (outcome.ok)
146
+ this.release(outcome.value);
147
+ });
148
+ }
149
+ this.stop(CANCELLED);
150
+ this.notify();
151
+ }
152
+ notify() {
153
+ this.wake?.();
154
+ }
155
+ fill() {
156
+ const capacity = this.lookahead + 1 - (this.current ? 1 : 0);
157
+ while (!this.cancelled && this.ahead.length < capacity && this.pending.length > 0) {
158
+ const text = this.pending.shift();
159
+ const controller = new AbortController;
160
+ this.ahead.push({
161
+ text,
162
+ controller,
163
+ result: settle(() => this.options.prepare(text, controller.signal))
164
+ });
165
+ }
166
+ }
167
+ async run() {
168
+ while (!this.cancelled) {
169
+ this.fill();
170
+ const slot = this.ahead.shift();
171
+ if (!slot) {
172
+ if (this.closed)
173
+ return;
174
+ await new Promise((resolve) => {
175
+ this.wake = resolve;
176
+ });
177
+ this.wake = null;
178
+ continue;
179
+ }
180
+ this.current = slot;
181
+ try {
182
+ const prepared = await Promise.race([slot.result, this.stopped]);
183
+ if (prepared === CANCELLED)
184
+ return;
185
+ if (!prepared.ok) {
186
+ this.report(prepared.error, slot.text);
187
+ continue;
188
+ }
189
+ this.playing = true;
190
+ this.fill();
191
+ const played = await Promise.race([
192
+ settle(() => this.options.play(prepared.value, slot.controller.signal)),
193
+ this.stopped
194
+ ]);
195
+ if (played === CANCELLED)
196
+ return;
197
+ if (!played.ok)
198
+ this.report(played.error, slot.text);
199
+ } finally {
200
+ this.playing = false;
201
+ this.current = null;
202
+ }
203
+ }
204
+ }
205
+ report(error, text) {
206
+ try {
207
+ this.options.onError?.(error, text);
208
+ } catch {}
209
+ }
210
+ release(prepared) {
211
+ try {
212
+ this.options.discard?.(prepared);
213
+ } catch {}
214
+ }
215
+ }
216
+ export {
217
+ speakableText,
218
+ SpeechQueue,
219
+ SentenceCutter,
220
+ LIVE_VOICE_PHASES
221
+ };
@@ -1152,7 +1152,11 @@ function addUsage(total, step) {
1152
1152
  };
1153
1153
  }
1154
1154
  function mergeModelTotals(sdkTotal, accumulated) {
1155
- const pick = (total, ours) => total?.value !== undefined ? { value: total.value, provenance: "computed" } : ours ?? { provenance: "unavailable" };
1155
+ const pick = (total, ours) => {
1156
+ if (ours?.provenance === "unavailable")
1157
+ return ours;
1158
+ return total?.value !== undefined ? { value: total.value, provenance: "computed" } : ours ?? { provenance: "unavailable" };
1159
+ };
1156
1160
  return {
1157
1161
  inputTokens: pick(sdkTotal.inputTokens, accumulated?.inputTokens),
1158
1162
  outputTokens: pick(sdkTotal.outputTokens, accumulated?.outputTokens),
@@ -1 +1 @@
1
- {"version":3,"file":"watch-client.d.ts","sourceRoot":"","sources":["../../src/live/watch-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"watch-client.d.ts","sourceRoot":"","sources":["../../src/live/watch-client.ts"],"names":[],"mappings":"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"}
@@ -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"}
@@ -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 |
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 |
@@ -12633,6 +12634,88 @@ session query with `cache`; the generic starter does not invent a `getSession`
12633
12634
  endpoint or cookie vocabulary.
12634
12635
 
12635
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
+
12636
12719
  ==============================================================================
12637
12720
  # Guide: GeoIP (docs/guide/geo.md)
12638
12721
  ==============================================================================
@@ -18300,6 +18383,21 @@ signal stay with the application. → ADR 0167 — [guide](../guide/release.md)
18300
18383
 
18301
18384
  ---
18302
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
+
18303
18401
  ## `stitchkit/geo`
18304
18402
 
18305
18403
  Server-only managed GeoIP generations. Applications own database acquisition,
package/llms.txt CHANGED
@@ -9,7 +9,7 @@ Build with stitchkit: define a contract once, then `implement` it and serve it (
9
9
  Load the slice for each entrypoint your code imports — `import … from 'stitchkit/server'` → `llms/server.txt`; the root `stitchkit` import → `llms/stitchkit.txt`. A slice holds that entrypoint's API reference section and the guide pages whose home it is; every guide lives in exactly one slice, and a slice that needs another's guide names it under "Also read". Each file is at most 50.0 KB; a longer one continues in numbered parts, each naming the next at its top. Moving across versions: load `llms/upgrading.txt` and the range slices it lists between your version and the target. `llms-full.txt` inlines everything; it is far larger than a context window, so do not read it whole.
10
10
 
11
11
  ## Entrypoint slices
12
- - **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 15.3 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 35.0 KB → [llms/stitchkit.3.txt](llms/stitchkit.3.txt) 14.2 KB
12
+ - **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 15.5 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 35.0 KB → [llms/stitchkit.3.txt](llms/stitchkit.3.txt) 14.2 KB
13
13
  - **stitchkit/live**: [llms/live.txt](llms/live.txt) 21.2 KB
14
14
  - **stitchkit/react**: [llms/react.txt](llms/react.txt) 8.6 KB
15
15
  - **stitchkit/tools**: [llms/tools.txt](llms/tools.txt) 44.9 KB → [llms/tools.2.txt](llms/tools.2.txt) 26.3 KB → [llms/tools.3.txt](llms/tools.3.txt) 25.4 KB
@@ -44,6 +44,7 @@ Load the slice for each entrypoint your code imports — `import … from 'stitc
44
44
  - **stitchkit/release**: [llms/release.txt](llms/release.txt) 8.9 KB
45
45
  - **stitchkit/tracking/server**: [llms/tracking-server.txt](llms/tracking-server.txt) 2.5 KB
46
46
  - **stitchkit/geo**: [llms/geo.txt](llms/geo.txt) 4.3 KB
47
+ - **stitchkit/voice**: [llms/voice.txt](llms/voice.txt) 5.2 KB
47
48
  - **stitchkit/oauth**: [llms/oauth.txt](llms/oauth.txt) 6.7 KB
48
49
  - **stitchkit/google**: [llms/google.txt](llms/google.txt) 1.4 KB
49
50
  - **stitchkit/declaration**: [llms/declaration.txt](llms/declaration.txt) 16.0 KB
@@ -78,6 +79,7 @@ Load the slice for each entrypoint your code imports — `import … from 'stitc
78
79
  - [Multi-tenant](https://github.com/max-listov/stitchkit/blob/master/docs/guide/multi-tenant.md): a /tenants/:id/… scenario end-to-end — scopePrefixes, scoped client, extend
79
80
  - [Frontend integrations](https://github.com/max-listov/stitchkit/blob/master/docs/guide/frontend-integrations.md): React Router resource routes and a separate Vite development proxy
80
81
  - [React Query policy](https://github.com/max-listov/stitchkit/blob/master/docs/guide/react.md): request-local SSR clients, a browser singleton and ApiError retry rules
82
+ - [Voice](https://github.com/max-listov/stitchkit/blob/master/docs/guide/voice.md): stitchkit/voice — sentences cut while a reply streams, the text a voice reads, a speech queue that synthesises ahead and stops on interruption
81
83
  - [GeoIP](https://github.com/max-listov/stitchkit/blob/master/docs/guide/geo.md): stitchkit/geo — a server-only managed reader, three observable states and last-known-good generation reloads
82
84
  - [Upgrading](https://github.com/max-listov/stitchkit/blob/master/docs/guide/upgrading.md): moving a project across stitchkit versions; how breaking changes are marked
83
85
  - [Project declaration](https://github.com/max-listov/stitchkit/blob/master/docs/guide/declaration.md): the optional machine-readable statement a repository makes about itself — identity, roles, build, requirements, release steps and the names of the values a deployment supplies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.95.1",
3
+ "version": "0.95.3",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",
@@ -190,6 +190,10 @@
190
190
  "types": "./dist/entrypoints/geo.d.ts",
191
191
  "import": "./dist/entrypoints/geo.js"
192
192
  },
193
+ "./voice": {
194
+ "types": "./dist/entrypoints/voice.d.ts",
195
+ "import": "./dist/entrypoints/voice.js"
196
+ },
193
197
  "./oauth": {
194
198
  "types": "./dist/entrypoints/oauth.d.ts",
195
199
  "import": "./dist/entrypoints/oauth.js"