stitchkit 0.95.1 → 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.
- package/CHANGELOG.md +26 -0
- package/dist/entrypoints/voice.d.ts +6 -0
- package/dist/entrypoints/voice.d.ts.map +1 -0
- package/dist/entrypoints/voice.js +221 -0
- package/dist/voice/phases.d.ts +4 -0
- package/dist/voice/phases.d.ts.map +1 -0
- package/dist/voice/sentences.d.ts +8 -0
- package/dist/voice/sentences.d.ts.map +1 -0
- package/dist/voice/speakable.d.ts +2 -0
- package/dist/voice/speakable.d.ts.map +1 -0
- package/dist/voice/speech-queue.d.ts +42 -0
- package/dist/voice/speech-queue.d.ts.map +1 -0
- package/llms/stitchkit.txt +1 -0
- package/llms/voice.txt +106 -0
- package/llms-full.txt +98 -0
- package/llms.txt +3 -1
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,32 @@ additive**; the first breaking change landed in 0.10.0. Grep the file for
|
|
|
15
15
|
|
|
16
16
|
## [Unreleased]
|
|
17
17
|
|
|
18
|
+
## [0.95.2] — 2026-09-23
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `stitchkit/voice` (new, browser **and** server, evolving, no dependencies) — the
|
|
23
|
+
mechanics of a live voice conversation with an agent that applications were each
|
|
24
|
+
carrying a copy of:
|
|
25
|
+
- **`SentenceCutter`** — `next(text, final)` cuts a reply into finished sentences
|
|
26
|
+
while it streams, so a voice starts on the first one; not at `3.5`, `т. е.` or an
|
|
27
|
+
ellipsis before lowercase, never inside a fenced code block; `reset()` starts a
|
|
28
|
+
new turn.
|
|
29
|
+
- **`speakableText(markdown)`** — what a reader sees, without Markdown delimiters,
|
|
30
|
+
link and image addresses, bare addresses, fenced code blocks or tables.
|
|
31
|
+
- **`SpeechQueue<Prepared>({ prepare, play, lookahead, onError, discard })`** —
|
|
32
|
+
speaks sentences in order and synthesises up to `lookahead` (default 1) while one
|
|
33
|
+
plays, so a synthesiser that returns whole clips leaves no gap between sentences;
|
|
34
|
+
`close()` finishes what is queued, `cancel()` aborts the playing sentence and every
|
|
35
|
+
synthesis in flight and settles `done` at once; a failed sentence is reported and
|
|
36
|
+
skipped.
|
|
37
|
+
- **`LIVE_VOICE_PHASES` / `LiveVoicePhase`** — `opening`, `idle`, `hearing`,
|
|
38
|
+
`thinking`, `speaking`.
|
|
39
|
+
|
|
40
|
+
Migration for an application with its own copy: import these four from
|
|
41
|
+
`stitchkit/voice` and delete the copy. A sequential queue over a streaming
|
|
42
|
+
synthesiser is `lookahead: 0`.
|
|
43
|
+
|
|
18
44
|
## [0.95.1] — 2026-09-23
|
|
19
45
|
|
|
20
46
|
### Added
|
|
@@ -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
|
+
};
|
|
@@ -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 @@
|
|
|
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"}
|
package/llms/stitchkit.txt
CHANGED
|
@@ -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.
|
|
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.
|
|
3
|
+
"version": "0.95.2",
|
|
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"
|