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.
- package/CHANGELOG.md +43 -0
- package/dist/application/directory-inbox-contract.d.ts +129 -0
- package/dist/application/directory-inbox-contract.d.ts.map +1 -0
- package/dist/application/directory-inbox.d.ts +3 -0
- package/dist/application/directory-inbox.d.ts.map +1 -0
- package/dist/entrypoints/application/directory-inbox.d.ts +11 -0
- package/dist/entrypoints/application/directory-inbox.d.ts.map +1 -0
- package/dist/entrypoints/application/directory-inbox.js +221 -0
- package/dist/entrypoints/application.d.ts +1 -0
- package/dist/entrypoints/application.d.ts.map +1 -1
- package/dist/entrypoints/application.js +6 -0
- package/dist/entrypoints/server.js +6 -219
- 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/index-5jrd0yng.js +114 -0
- package/dist/index-tm88avnj.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/application-directory-inbox.txt +9 -0
- package/llms/application.2.txt +2 -0
- package/llms/application.txt +30 -1
- package/llms/stitchkit.txt +2 -0
- package/llms/voice.txt +106 -0
- package/llms-full.txt +131 -1
- package/llms.txt +5 -2
- package/package.json +9 -1
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
webSocketLane
|
|
22
22
|
} from "../index-p3vsv1fb.js";
|
|
23
23
|
import"../index-y91ry9h2.js";
|
|
24
|
+
import {
|
|
25
|
+
createFileStateStore
|
|
26
|
+
} from "../index-tm88avnj.js";
|
|
24
27
|
import {
|
|
25
28
|
withDeadline
|
|
26
29
|
} from "../index-b9aefrqd.js";
|
|
@@ -113,222 +116,6 @@ import {
|
|
|
113
116
|
import {
|
|
114
117
|
isRecord
|
|
115
118
|
} from "../index-hffjpmq9.js";
|
|
116
|
-
|
|
117
|
-
// src/application/file-state-store.ts
|
|
118
|
-
import { mkdir, open, readdir, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
119
|
-
import { basename, dirname, join } from "node:path";
|
|
120
|
-
var ABANDONED_LOCK_MULTIPLIER = 10;
|
|
121
|
-
var ABANDONED_HEARTBEATS = 30;
|
|
122
|
-
var MIN_HEARTBEAT_MS = 10;
|
|
123
|
-
var delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
124
|
-
async function missing(error) {
|
|
125
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
126
|
-
}
|
|
127
|
-
function existing(error) {
|
|
128
|
-
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
129
|
-
}
|
|
130
|
-
async function ownerState(lockPath) {
|
|
131
|
-
let owner;
|
|
132
|
-
try {
|
|
133
|
-
owner = JSON.parse(await readFile(lockPath, "utf8"));
|
|
134
|
-
} catch {
|
|
135
|
-
return "unknown";
|
|
136
|
-
}
|
|
137
|
-
if (typeof owner !== "object" || owner === null || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
|
|
138
|
-
return "unknown";
|
|
139
|
-
}
|
|
140
|
-
try {
|
|
141
|
-
process.kill(owner.pid, 0);
|
|
142
|
-
return "alive";
|
|
143
|
-
} catch (error) {
|
|
144
|
-
return error instanceof Error && "code" in error && error.code === "ESRCH" ? "gone" : "unknown";
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
async function sweepTemporaries(path, staleLockMs) {
|
|
148
|
-
const prefix = `${basename(path)}.tmp.`;
|
|
149
|
-
const directory = dirname(path);
|
|
150
|
-
let entries;
|
|
151
|
-
try {
|
|
152
|
-
entries = await readdir(directory);
|
|
153
|
-
} catch {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
for (const entry of entries) {
|
|
157
|
-
if (!entry.startsWith(prefix))
|
|
158
|
-
continue;
|
|
159
|
-
const candidate = join(directory, entry);
|
|
160
|
-
const info = await stat(candidate).catch(() => null);
|
|
161
|
-
if (info && Date.now() - info.mtimeMs > staleLockMs) {
|
|
162
|
-
await unlink(candidate).catch(() => {
|
|
163
|
-
return;
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
async function reclaim(lockPath, observed, staleLockMs, abandonedAfterMs) {
|
|
169
|
-
const guardPath = `${lockPath}.reclaim`;
|
|
170
|
-
let guard;
|
|
171
|
-
try {
|
|
172
|
-
guard = await open(guardPath, "wx");
|
|
173
|
-
} catch (error) {
|
|
174
|
-
if (!existing(error))
|
|
175
|
-
throw error;
|
|
176
|
-
const orphan = await stat(guardPath).catch(() => null);
|
|
177
|
-
if (orphan && Date.now() - orphan.mtimeMs > staleLockMs) {
|
|
178
|
-
await unlink(guardPath).catch(() => {
|
|
179
|
-
return;
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
return false;
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
const current = await stat(lockPath).catch(() => null);
|
|
186
|
-
if (!current || current.dev !== observed.dev || current.ino !== observed.ino || current.mtimeMs !== observed.mtimeMs) {
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
const owner = await ownerState(lockPath);
|
|
190
|
-
const age = Date.now() - current.mtimeMs;
|
|
191
|
-
if (owner !== "gone" && age < abandonedAfterMs)
|
|
192
|
-
return false;
|
|
193
|
-
await unlink(lockPath);
|
|
194
|
-
return true;
|
|
195
|
-
} finally {
|
|
196
|
-
await guard.close().catch(() => {
|
|
197
|
-
return;
|
|
198
|
-
});
|
|
199
|
-
await unlink(guardPath).catch(() => {
|
|
200
|
-
return;
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
function createFileStateStore(path, options) {
|
|
205
|
-
const lockPath = `${path}.lock`;
|
|
206
|
-
const lockTimeoutMs = options.lockTimeoutMs ?? 1e4;
|
|
207
|
-
const staleLockMs = options.staleLockMs ?? 3000;
|
|
208
|
-
const retryMs = options.retryMs ?? 10;
|
|
209
|
-
const heartbeatMs = Math.max(MIN_HEARTBEAT_MS, Math.floor(staleLockMs / 3));
|
|
210
|
-
const abandonedAfterMs = Math.max(staleLockMs * ABANDONED_LOCK_MULTIPLIER, heartbeatMs * ABANDONED_HEARTBEATS);
|
|
211
|
-
if (staleLockMs >= lockTimeoutMs) {
|
|
212
|
-
throw new Error(`[stitchkit] createFileStateStore: staleLockMs (${staleLockMs}) must be below lockTimeoutMs (${lockTimeoutMs}), or a crashed holder blocks every update until the timeout`);
|
|
213
|
-
}
|
|
214
|
-
let sweptTemporaries = false;
|
|
215
|
-
const parseFile = async () => {
|
|
216
|
-
let source;
|
|
217
|
-
try {
|
|
218
|
-
source = await readFile(path, "utf8");
|
|
219
|
-
} catch (error) {
|
|
220
|
-
if (await missing(error))
|
|
221
|
-
return null;
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
try {
|
|
225
|
-
return options.schema.parse(JSON.parse(source));
|
|
226
|
-
} catch (error) {
|
|
227
|
-
await options.onCorrupt?.({ path, error });
|
|
228
|
-
if ((options.corrupt ?? "throw") === "empty")
|
|
229
|
-
return null;
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
const acquire = async () => {
|
|
234
|
-
await mkdir(dirname(path), { recursive: true });
|
|
235
|
-
if (!sweptTemporaries) {
|
|
236
|
-
sweptTemporaries = true;
|
|
237
|
-
await sweepTemporaries(path, staleLockMs);
|
|
238
|
-
}
|
|
239
|
-
const deadline = Date.now() + lockTimeoutMs;
|
|
240
|
-
while (true) {
|
|
241
|
-
try {
|
|
242
|
-
const handle = await open(lockPath, "wx");
|
|
243
|
-
const token = crypto.randomUUID();
|
|
244
|
-
try {
|
|
245
|
-
await handle.writeFile(JSON.stringify({ token, pid: process.pid, acquiredAt: Date.now() }));
|
|
246
|
-
await handle.sync();
|
|
247
|
-
} catch (error) {
|
|
248
|
-
await handle.close().catch(() => {
|
|
249
|
-
return;
|
|
250
|
-
});
|
|
251
|
-
await unlink(lockPath).catch(() => {
|
|
252
|
-
return;
|
|
253
|
-
});
|
|
254
|
-
throw error;
|
|
255
|
-
}
|
|
256
|
-
return {
|
|
257
|
-
refresh: async () => {
|
|
258
|
-
const now = new Date;
|
|
259
|
-
await handle.utimes(now, now);
|
|
260
|
-
},
|
|
261
|
-
release: async () => {
|
|
262
|
-
await handle.close();
|
|
263
|
-
try {
|
|
264
|
-
const owner = JSON.parse(await readFile(lockPath, "utf8"));
|
|
265
|
-
if (isRecord(owner) && owner.token === token)
|
|
266
|
-
await unlink(lockPath);
|
|
267
|
-
} catch (error) {
|
|
268
|
-
if (!await missing(error))
|
|
269
|
-
throw error;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (!existing(error))
|
|
275
|
-
throw error;
|
|
276
|
-
try {
|
|
277
|
-
const lock = await stat(lockPath);
|
|
278
|
-
if (Date.now() - lock.mtimeMs > staleLockMs) {
|
|
279
|
-
if (await reclaim(lockPath, lock, staleLockMs, abandonedAfterMs))
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
} catch (statError) {
|
|
283
|
-
if (await missing(statError))
|
|
284
|
-
continue;
|
|
285
|
-
throw statError;
|
|
286
|
-
}
|
|
287
|
-
if (Date.now() >= deadline) {
|
|
288
|
-
throw new Error(`[stitchkit] timed out acquiring state lock for ${path}`);
|
|
289
|
-
}
|
|
290
|
-
await delay(retryMs);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
const write = async (state) => {
|
|
295
|
-
const validated = options.schema.parse(state);
|
|
296
|
-
const temporary = `${path}.tmp.${process.pid}.${crypto.randomUUID()}`;
|
|
297
|
-
const handle = await open(temporary, "wx");
|
|
298
|
-
try {
|
|
299
|
-
try {
|
|
300
|
-
await handle.writeFile(`${JSON.stringify(validated)}
|
|
301
|
-
`);
|
|
302
|
-
await handle.sync();
|
|
303
|
-
} finally {
|
|
304
|
-
await handle.close();
|
|
305
|
-
}
|
|
306
|
-
await rename(temporary, path);
|
|
307
|
-
} catch (error) {
|
|
308
|
-
await unlink(temporary).catch(() => {
|
|
309
|
-
return;
|
|
310
|
-
});
|
|
311
|
-
throw error;
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
return {
|
|
315
|
-
read: parseFile,
|
|
316
|
-
async update(transition) {
|
|
317
|
-
const lock = await acquire();
|
|
318
|
-
const heartbeat = setInterval(() => void lock.refresh().catch(() => {
|
|
319
|
-
return;
|
|
320
|
-
}), heartbeatMs);
|
|
321
|
-
try {
|
|
322
|
-
const next = await transition(await parseFile());
|
|
323
|
-
await write(next.state);
|
|
324
|
-
return next.result;
|
|
325
|
-
} finally {
|
|
326
|
-
clearInterval(heartbeat);
|
|
327
|
-
await lock.release();
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
119
|
// src/realtime/registry.ts
|
|
333
120
|
var authorizedRoom = Symbol("stitchkit.authorized-room");
|
|
334
121
|
function freezeRoom(name) {
|
|
@@ -1001,7 +788,7 @@ function createEventBus(options = {}) {
|
|
|
1001
788
|
};
|
|
1002
789
|
}
|
|
1003
790
|
// src/server/file.ts
|
|
1004
|
-
import { readFile
|
|
791
|
+
import { readFile, stat } from "node:fs/promises";
|
|
1005
792
|
import { resolve } from "node:path";
|
|
1006
793
|
|
|
1007
794
|
// src/server/mime.ts
|
|
@@ -1061,10 +848,10 @@ function staticRoute(prefix, dir) {
|
|
|
1061
848
|
const realTarget = await realPathWithinDir(root, target);
|
|
1062
849
|
if (realTarget === null)
|
|
1063
850
|
return new Response("Not found", { status: 404 });
|
|
1064
|
-
const info = await
|
|
851
|
+
const info = await stat(realTarget).catch(() => null);
|
|
1065
852
|
if (!info?.isFile())
|
|
1066
853
|
return new Response("Not found", { status: 404 });
|
|
1067
|
-
return new Response(await
|
|
854
|
+
return new Response(await readFile(realTarget), {
|
|
1068
855
|
headers: {
|
|
1069
856
|
"Content-Type": mimeForPath(realTarget),
|
|
1070
857
|
"X-Content-Type-Options": "nosniff"
|
|
@@ -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,114 @@
|
|
|
1
|
+
// src/application/directory-inbox-contract.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var timestamp = z.string().datetime({ offset: true });
|
|
4
|
+
var key = z.string().min(1).max(255);
|
|
5
|
+
var DirectoryInboxRejectionReasonSchema = z.enum([
|
|
6
|
+
"invalid",
|
|
7
|
+
"too-large",
|
|
8
|
+
"attempt-limit"
|
|
9
|
+
]);
|
|
10
|
+
var DirectoryInboxStateSchema = z.object({
|
|
11
|
+
schemaVersion: z.literal(1),
|
|
12
|
+
claims: z.array(z.object({
|
|
13
|
+
key,
|
|
14
|
+
attempts: z.number().int().positive(),
|
|
15
|
+
nextAttemptAt: timestamp,
|
|
16
|
+
leaseId: z.string().min(1).max(128).nullable(),
|
|
17
|
+
leaseUntil: timestamp.nullable()
|
|
18
|
+
}).strict()),
|
|
19
|
+
receipts: z.array(z.object({ key, completedAt: timestamp }).strict()),
|
|
20
|
+
rejected: z.array(z.object({
|
|
21
|
+
key,
|
|
22
|
+
reason: DirectoryInboxRejectionReasonSchema,
|
|
23
|
+
detail: z.string().max(1000),
|
|
24
|
+
rejectedAt: timestamp
|
|
25
|
+
}).strict())
|
|
26
|
+
}).strict();
|
|
27
|
+
var emptyDirectoryInboxState = () => ({
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
claims: [],
|
|
30
|
+
receipts: [],
|
|
31
|
+
rejected: []
|
|
32
|
+
});
|
|
33
|
+
var iso = (milliseconds) => new Date(milliseconds).toISOString();
|
|
34
|
+
function withRejection(state, rejection, retain) {
|
|
35
|
+
return {
|
|
36
|
+
...state,
|
|
37
|
+
claims: state.claims.filter((claim) => claim.key !== rejection.key),
|
|
38
|
+
rejected: [
|
|
39
|
+
rejection,
|
|
40
|
+
...state.rejected.filter((item) => item.key !== rejection.key)
|
|
41
|
+
].slice(0, retain)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function claimEntry(state, input) {
|
|
45
|
+
if (state.receipts.some((receipt) => receipt.key === input.key)) {
|
|
46
|
+
return { state, step: { kind: "remove" } };
|
|
47
|
+
}
|
|
48
|
+
if (state.rejected.some((rejection) => rejection.key === input.key)) {
|
|
49
|
+
return { state, step: { kind: "set-aside" } };
|
|
50
|
+
}
|
|
51
|
+
const now = input.now.getTime();
|
|
52
|
+
const claim = state.claims.find((candidate) => candidate.key === input.key);
|
|
53
|
+
if (claim) {
|
|
54
|
+
const leased = claim.leaseUntil !== null && Date.parse(claim.leaseUntil) > now;
|
|
55
|
+
if (leased || Date.parse(claim.nextAttemptAt) > now)
|
|
56
|
+
return { state, step: { kind: "skip" } };
|
|
57
|
+
if (claim.attempts >= input.maxAttempts) {
|
|
58
|
+
const rejection = {
|
|
59
|
+
key: input.key,
|
|
60
|
+
reason: "attempt-limit",
|
|
61
|
+
detail: `taken ${claim.attempts} times without completing`,
|
|
62
|
+
rejectedAt: iso(now)
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
state: withRejection(state, rejection, input.retain),
|
|
66
|
+
step: { kind: "reject", rejection }
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const taken = {
|
|
71
|
+
key: input.key,
|
|
72
|
+
attempts: (claim?.attempts ?? 0) + 1,
|
|
73
|
+
nextAttemptAt: iso(now),
|
|
74
|
+
leaseId: crypto.randomUUID(),
|
|
75
|
+
leaseUntil: iso(now + input.leaseMs)
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
state: {
|
|
79
|
+
...state,
|
|
80
|
+
claims: [...state.claims.filter((candidate) => candidate.key !== input.key), taken]
|
|
81
|
+
},
|
|
82
|
+
step: { kind: "deliver", claim: taken }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function completeEntry(state, entryKey, now, retain) {
|
|
86
|
+
return {
|
|
87
|
+
...state,
|
|
88
|
+
claims: state.claims.filter((claim) => claim.key !== entryKey),
|
|
89
|
+
receipts: [
|
|
90
|
+
{ key: entryKey, completedAt: now.toISOString() },
|
|
91
|
+
...state.receipts.filter((receipt) => receipt.key !== entryKey)
|
|
92
|
+
].slice(0, retain)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function releaseEntry(state, claim, retryAt) {
|
|
96
|
+
return {
|
|
97
|
+
...state,
|
|
98
|
+
claims: state.claims.map((candidate) => candidate.key === claim.key && candidate.leaseId === claim.leaseId ? {
|
|
99
|
+
...candidate,
|
|
100
|
+
nextAttemptAt: retryAt.toISOString(),
|
|
101
|
+
leaseId: null,
|
|
102
|
+
leaseUntil: null
|
|
103
|
+
} : candidate)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function forgetMissing(state, present, now) {
|
|
107
|
+
const live = (claim) => claim.leaseUntil !== null && Date.parse(claim.leaseUntil) > now.getTime();
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
claims: state.claims.filter((claim) => present.has(claim.key) || live(claim))
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { DirectoryInboxRejectionReasonSchema, DirectoryInboxStateSchema, emptyDirectoryInboxState, withRejection, claimEntry, completeEntry, releaseEntry, forgetMissing };
|