@mulmoclaude/core 0.2.14 → 0.2.16
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/dist/collection/registry/guards.d.ts +2 -0
- package/dist/collection/registry/index.cjs +4 -0
- package/dist/collection/registry/index.d.ts +2 -0
- package/dist/collection/registry/index.js +2 -0
- package/dist/collection/registry/registryIndex.d.ts +48 -0
- package/dist/collection/registry/server/client.d.ts +60 -0
- package/dist/collection/registry/server/collectionFiles.d.ts +51 -0
- package/dist/collection/registry/server/exportCollection.d.ts +29 -0
- package/dist/collection/registry/server/fetch.d.ts +8 -0
- package/dist/collection/registry/server/importCollection.d.ts +35 -0
- package/dist/collection/registry/server/importWriter.d.ts +30 -0
- package/dist/collection/registry/server/index.cjs +1110 -0
- package/dist/collection/registry/server/index.cjs.map +1 -0
- package/dist/collection/registry/server/index.d.ts +28 -0
- package/dist/collection/registry/server/index.js +1076 -0
- package/dist/collection/registry/server/index.js.map +1 -0
- package/dist/collection/registry/server/performExport.d.ts +6 -0
- package/dist/collection/registry/server/registriesConfig.d.ts +11 -0
- package/dist/collection/registry/server/skillDescription.d.ts +4 -0
- package/dist/collection/registry/types.d.ts +37 -0
- package/dist/collection/server/host.d.ts +7 -0
- package/dist/collection/server/index.cjs +1 -1
- package/dist/collection/server/index.js +1 -1
- package/dist/collection/server/util.d.ts +1 -0
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +1 -1
- package/dist/feeds/server/index.cjs +1 -1
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +1 -1
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/notifier-ChpY0XrY.js.map +1 -1
- package/dist/notifier-bS8IEeLA.cjs.map +1 -1
- package/dist/{server-CRlmOBVQ.js → server-DRoqc8dL.js} +8 -2
- package/dist/server-DRoqc8dL.js.map +1 -0
- package/dist/{server-BjXvqGrE.cjs → server-DoDXibCq.cjs} +31 -1
- package/dist/server-DoDXibCq.cjs.map +1 -0
- package/dist/translation/client.cjs +45 -0
- package/dist/translation/client.cjs.map +1 -0
- package/dist/translation/client.d.ts +32 -0
- package/dist/translation/client.js +44 -0
- package/dist/translation/client.js.map +1 -0
- package/dist/types-BKVZrtyc.js +123 -0
- package/dist/types-BKVZrtyc.js.map +1 -0
- package/dist/types-CNqkLT4M.cjs +140 -0
- package/dist/types-CNqkLT4M.cjs.map +1 -0
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.js.map +1 -1
- package/package.json +20 -2
- package/dist/server-BjXvqGrE.cjs.map +0 -1
- package/dist/server-CRlmOBVQ.js.map +0 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//#region src/collection/registry/guards.ts
|
|
2
|
+
/** Narrow `unknown` to a plain object (not null, not array). */
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/collection/registry/registryIndex.ts
|
|
8
|
+
var SUPPORTED_SCHEMA_VERSION = 1;
|
|
9
|
+
var pickString = (rec, key) => {
|
|
10
|
+
const value = rec[key];
|
|
11
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
12
|
+
};
|
|
13
|
+
function validatedCount(rec, key) {
|
|
14
|
+
const value = rec[key];
|
|
15
|
+
if (value === void 0) return 0;
|
|
16
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : "invalid";
|
|
17
|
+
}
|
|
18
|
+
var pickBoolean = (rec, key) => {
|
|
19
|
+
const value = rec[key];
|
|
20
|
+
return typeof value === "boolean" ? value : void 0;
|
|
21
|
+
};
|
|
22
|
+
var asStringArray = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
23
|
+
var AUTHOR_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;
|
|
24
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
25
|
+
function identityError(entryId, author, slug, dirPath, index) {
|
|
26
|
+
if (!AUTHOR_RE.test(author)) return `collections[${index}].author has an invalid format`;
|
|
27
|
+
if (!SLUG_RE.test(slug)) return `collections[${index}].slug has an invalid format`;
|
|
28
|
+
if (entryId !== `${author}/${slug}`) return `collections[${index}].id must equal "${author}/${slug}"`;
|
|
29
|
+
if (dirPath !== `collections/${author}/${slug}`) return `collections[${index}].path must equal "collections/${author}/${slug}"`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function validatedCounts(value, index) {
|
|
33
|
+
const fieldCount = validatedCount(value, "fieldCount");
|
|
34
|
+
const seedCount = validatedCount(value, "seedCount");
|
|
35
|
+
if (fieldCount === "invalid") return `collections[${index}].fieldCount must be a non-negative integer`;
|
|
36
|
+
if (seedCount === "invalid") return `collections[${index}].seedCount must be a non-negative integer`;
|
|
37
|
+
return {
|
|
38
|
+
fieldCount,
|
|
39
|
+
seedCount
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function parseEntry(value, index, registryName) {
|
|
43
|
+
if (!isRecord(value)) return `collections[${index}] is not an object`;
|
|
44
|
+
const entryId = pickString(value, "id");
|
|
45
|
+
const author = pickString(value, "author");
|
|
46
|
+
const slug = pickString(value, "slug");
|
|
47
|
+
const title = pickString(value, "title");
|
|
48
|
+
const version = pickString(value, "version");
|
|
49
|
+
const path = pickString(value, "path");
|
|
50
|
+
const contentSha = pickString(value, "contentSha");
|
|
51
|
+
if (!entryId || !author || !slug || !title || !version || !path || !contentSha) return `collections[${index}] is missing a required string field (id/author/slug/title/version/path/contentSha)`;
|
|
52
|
+
const mismatch = identityError(entryId, author, slug, path, index);
|
|
53
|
+
if (mismatch) return mismatch;
|
|
54
|
+
const counts = validatedCounts(value, index);
|
|
55
|
+
if (typeof counts === "string") return counts;
|
|
56
|
+
return {
|
|
57
|
+
id: entryId,
|
|
58
|
+
author,
|
|
59
|
+
slug,
|
|
60
|
+
title,
|
|
61
|
+
version,
|
|
62
|
+
path,
|
|
63
|
+
contentSha,
|
|
64
|
+
icon: pickString(value, "icon") ?? "",
|
|
65
|
+
description: pickString(value, "description") ?? "",
|
|
66
|
+
license: pickString(value, "license") ?? "",
|
|
67
|
+
tags: asStringArray(value.tags),
|
|
68
|
+
views: asStringArray(value.views),
|
|
69
|
+
fieldCount: counts.fieldCount,
|
|
70
|
+
seedCount: counts.seedCount,
|
|
71
|
+
hasSeed: pickBoolean(value, "hasSeed") ?? false,
|
|
72
|
+
screenshot: pickString(value, "screenshot"),
|
|
73
|
+
registryName
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Parse an index payload. `registryName` labels every entry produced — the
|
|
77
|
+
* caller passes "official" for the canonical registry or the user's config
|
|
78
|
+
* entry name. The label is needed downstream so import / preview can pick the
|
|
79
|
+
* right rawBase for per-collection files. */
|
|
80
|
+
function parseRegistryIndex(value, registryName) {
|
|
81
|
+
if (!isRecord(value)) return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: "index is not an object"
|
|
84
|
+
};
|
|
85
|
+
if (value.schemaVersion !== SUPPORTED_SCHEMA_VERSION) return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: `unsupported schemaVersion (expected ${SUPPORTED_SCHEMA_VERSION})`
|
|
88
|
+
};
|
|
89
|
+
const registry = pickString(value, "registry");
|
|
90
|
+
const generatedAt = pickString(value, "generatedAt");
|
|
91
|
+
if (!registry || !generatedAt) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: "index missing registry/generatedAt"
|
|
94
|
+
};
|
|
95
|
+
if (!Array.isArray(value.collections)) return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: "index.collections must be an array"
|
|
98
|
+
};
|
|
99
|
+
const parsed = value.collections.map((entry, idx) => parseEntry(entry, idx, registryName));
|
|
100
|
+
const firstError = parsed.find((entry) => typeof entry === "string");
|
|
101
|
+
if (firstError !== void 0) return {
|
|
102
|
+
ok: false,
|
|
103
|
+
error: firstError
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
index: {
|
|
108
|
+
schemaVersion: SUPPORTED_SCHEMA_VERSION,
|
|
109
|
+
registry,
|
|
110
|
+
generatedAt,
|
|
111
|
+
collections: parsed.filter((entry) => typeof entry !== "string")
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/collection/registry/types.ts
|
|
117
|
+
/** Reserved name for the official registry. The client always synthesizes one
|
|
118
|
+
* entry under this name; user config that re-uses it is rejected. */
|
|
119
|
+
var OFFICIAL_REGISTRY_NAME = "official";
|
|
120
|
+
//#endregion
|
|
121
|
+
export { parseRegistryIndex as n, isRecord as r, OFFICIAL_REGISTRY_NAME as t };
|
|
122
|
+
|
|
123
|
+
//# sourceMappingURL=types-BKVZrtyc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types-BKVZrtyc.js","names":[],"sources":["../src/collection/registry/guards.ts","../src/collection/registry/registryIndex.ts","../src/collection/registry/types.ts"],"sourcesContent":["/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n","// Parse + validate the curated collection registry's published index.json.\n// Pure — no I/O. The caller fetches the JSON and hands the parsed value here.\n// Mirrors the registry repo's schema/index.schema.json contract (schemaVersion 1):\n// receptron/mulmoclaude-collections.\n//\n// The index is host-fetched convenience data for the Discover catalog, not a\n// trust boundary: a collection's actual files are re-validated on import.\n\nimport { isRecord } from \"./guards.js\";\n\nexport interface RegistryEntry {\n /** `<author>/<slug>` — identity inside its source registry. NOT globally\n * unique when multiple registries are configured — pair with `registryName`\n * for a global key. */\n id: string;\n author: string;\n slug: string;\n title: string;\n icon: string;\n description: string;\n version: string;\n tags: string[];\n license: string;\n fieldCount: number;\n /** Custom view labels. */\n views: string[];\n hasSeed: boolean;\n seedCount: number;\n /** repo-relative path, omitted when absent. */\n screenshot?: string;\n /** repo-relative collection dir. */\n path: string;\n /** Stable bundle hash for update detection. */\n contentSha: string;\n /** Label of the source registry. Set by the client (not by the parser) when\n * the index is fetched, so the import / preview path can resolve the right\n * rawBase. `\"official\"` for receptron/mulmoclaude-collections; otherwise\n * the user's config entry name. */\n registryName: string;\n}\n\nexport interface RegistryIndex {\n schemaVersion: number;\n generatedAt: string;\n registry: string;\n collections: RegistryEntry[];\n}\n\nexport type ParseResult = { ok: true; index: RegistryIndex } | { ok: false; error: string };\n\nconst SUPPORTED_SCHEMA_VERSION = 1;\n\nconst pickString = (rec: Record<string, unknown>, key: string): string | undefined => {\n const value = rec[key];\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n};\n\n// Count fields (fieldCount/seedCount) must be non-negative integers. A present\n// but negative/fractional value is rejected rather than served to the UI; an\n// absent value defaults to 0.\nfunction validatedCount(rec: Record<string, unknown>, key: string): number | \"invalid\" {\n const value = rec[key];\n if (value === undefined) return 0;\n return typeof value === \"number\" && Number.isInteger(value) && value >= 0 ? value : \"invalid\";\n}\n\nconst pickBoolean = (rec: Record<string, unknown>, key: string): boolean | undefined => {\n const value = rec[key];\n return typeof value === \"boolean\" ? value : undefined;\n};\n\nconst asStringArray = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === \"string\") : []);\n\n// Identifier allowlists (mirror the registry's meta.schema.json). Crucially these\n// exclude `.`, `/`, `\\`, and `..`, so a poisoned author/slug can never make\n// collectionFileUrl() escape the collections/<author>/<slug> subtree — even if the\n// entry is internally consistent. Bounded quantifiers (no ReDoS).\nconst AUTHOR_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;\nconst SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;\n\n// author/slug must be allowlisted identifiers, and id/path must agree with them, so a\n// lookup by author+slug can never fetch files from a different (or out-of-tree)\n// directory than requested (defense against a poisoned index).\nfunction identityError(entryId: string, author: string, slug: string, dirPath: string, index: number): string | null {\n if (!AUTHOR_RE.test(author)) return `collections[${index}].author has an invalid format`;\n if (!SLUG_RE.test(slug)) return `collections[${index}].slug has an invalid format`;\n if (entryId !== `${author}/${slug}`) return `collections[${index}].id must equal \"${author}/${slug}\"`;\n if (dirPath !== `collections/${author}/${slug}`) return `collections[${index}].path must equal \"collections/${author}/${slug}\"`;\n return null;\n}\n\nfunction validatedCounts(value: Record<string, unknown>, index: number): { fieldCount: number; seedCount: number } | string {\n const fieldCount = validatedCount(value, \"fieldCount\");\n const seedCount = validatedCount(value, \"seedCount\");\n if (fieldCount === \"invalid\") return `collections[${index}].fieldCount must be a non-negative integer`;\n if (seedCount === \"invalid\") return `collections[${index}].seedCount must be a non-negative integer`;\n return { fieldCount, seedCount };\n}\n\nfunction parseEntry(value: unknown, index: number, registryName: string): RegistryEntry | string {\n if (!isRecord(value)) return `collections[${index}] is not an object`;\n const entryId = pickString(value, \"id\");\n const author = pickString(value, \"author\");\n const slug = pickString(value, \"slug\");\n const title = pickString(value, \"title\");\n const version = pickString(value, \"version\");\n const path = pickString(value, \"path\");\n const contentSha = pickString(value, \"contentSha\");\n if (!entryId || !author || !slug || !title || !version || !path || !contentSha) {\n return `collections[${index}] is missing a required string field (id/author/slug/title/version/path/contentSha)`;\n }\n const mismatch = identityError(entryId, author, slug, path, index);\n if (mismatch) return mismatch;\n const counts = validatedCounts(value, index);\n if (typeof counts === \"string\") return counts;\n return {\n id: entryId,\n author,\n slug,\n title,\n version,\n path,\n contentSha,\n icon: pickString(value, \"icon\") ?? \"\",\n description: pickString(value, \"description\") ?? \"\",\n license: pickString(value, \"license\") ?? \"\",\n tags: asStringArray(value.tags),\n views: asStringArray(value.views),\n fieldCount: counts.fieldCount,\n seedCount: counts.seedCount,\n hasSeed: pickBoolean(value, \"hasSeed\") ?? false,\n screenshot: pickString(value, \"screenshot\"),\n registryName,\n };\n}\n\n/** Parse an index payload. `registryName` labels every entry produced — the\n * caller passes \"official\" for the canonical registry or the user's config\n * entry name. The label is needed downstream so import / preview can pick the\n * right rawBase for per-collection files. */\nexport function parseRegistryIndex(value: unknown, registryName: string): ParseResult {\n if (!isRecord(value)) return { ok: false, error: \"index is not an object\" };\n if (value.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {\n return { ok: false, error: `unsupported schemaVersion (expected ${SUPPORTED_SCHEMA_VERSION})` };\n }\n const registry = pickString(value, \"registry\");\n const generatedAt = pickString(value, \"generatedAt\");\n if (!registry || !generatedAt) return { ok: false, error: \"index missing registry/generatedAt\" };\n if (!Array.isArray(value.collections)) return { ok: false, error: \"index.collections must be an array\" };\n\n const parsed = value.collections.map((entry, idx) => parseEntry(entry, idx, registryName));\n const firstError = parsed.find((entry): entry is string => typeof entry === \"string\");\n if (firstError !== undefined) return { ok: false, error: firstError };\n const collections = parsed.filter((entry): entry is RegistryEntry => typeof entry !== \"string\");\n return { ok: true, index: { schemaVersion: SUPPORTED_SCHEMA_VERSION, registry, generatedAt, collections } };\n}\n","// Isomorphic contract shared by the registry engine, the host routes, and the\n// collection plugin's Discover UI. No I/O — types + a reserved-name constant.\n\nimport type { RegistryEntry } from \"./registryIndex.js\";\n\nexport interface RegistryConfigEntry {\n /** Short label shown on Discover cards + used as the routing key. */\n name: string;\n /** Absolute HTTPS URL of the registry's index.json. */\n indexUrl: string;\n /** Absolute HTTPS base for per-collection files (no trailing slash). */\n rawBaseUrl: string;\n}\n\n/** Reserved name for the official registry. The client always synthesizes one\n * entry under this name; user config that re-uses it is rejected. */\nexport const OFFICIAL_REGISTRY_NAME = \"official\";\n\n/** Per-registry summary in the merged Discover response. */\nexport interface RegistrySummary {\n name: string;\n /** `ok` = fresh, `stale` = served from cache because the upstream failed,\n * `failed` = no cache to fall back to (the entries contribution is 0). */\n status: \"ok\" | \"stale\" | \"failed\";\n generatedAt: string | null;\n error: string | null;\n entryCount: number;\n}\n\n/** `GET …collectionsRegistry.list` — the Discover catalog merged across every\n * configured registry. */\nexport interface RegistryListResponse {\n registries: RegistrySummary[];\n /** Convenience flag: true iff any single registry's contribution was stale. */\n stale: boolean;\n collections: RegistryEntry[];\n}\n\n/** `POST …collectionsRegistry.import` — install result. */\nexport interface RegistryImportResponse {\n localSlug: string;\n updated: boolean;\n seedWritten: number;\n seedSkipped: boolean;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//#region src/collection/registry/guards.ts
|
|
2
|
+
/** Narrow `unknown` to a plain object (not null, not array). */
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/collection/registry/registryIndex.ts
|
|
8
|
+
var SUPPORTED_SCHEMA_VERSION = 1;
|
|
9
|
+
var pickString = (rec, key) => {
|
|
10
|
+
const value = rec[key];
|
|
11
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
12
|
+
};
|
|
13
|
+
function validatedCount(rec, key) {
|
|
14
|
+
const value = rec[key];
|
|
15
|
+
if (value === void 0) return 0;
|
|
16
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : "invalid";
|
|
17
|
+
}
|
|
18
|
+
var pickBoolean = (rec, key) => {
|
|
19
|
+
const value = rec[key];
|
|
20
|
+
return typeof value === "boolean" ? value : void 0;
|
|
21
|
+
};
|
|
22
|
+
var asStringArray = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
23
|
+
var AUTHOR_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;
|
|
24
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
25
|
+
function identityError(entryId, author, slug, dirPath, index) {
|
|
26
|
+
if (!AUTHOR_RE.test(author)) return `collections[${index}].author has an invalid format`;
|
|
27
|
+
if (!SLUG_RE.test(slug)) return `collections[${index}].slug has an invalid format`;
|
|
28
|
+
if (entryId !== `${author}/${slug}`) return `collections[${index}].id must equal "${author}/${slug}"`;
|
|
29
|
+
if (dirPath !== `collections/${author}/${slug}`) return `collections[${index}].path must equal "collections/${author}/${slug}"`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function validatedCounts(value, index) {
|
|
33
|
+
const fieldCount = validatedCount(value, "fieldCount");
|
|
34
|
+
const seedCount = validatedCount(value, "seedCount");
|
|
35
|
+
if (fieldCount === "invalid") return `collections[${index}].fieldCount must be a non-negative integer`;
|
|
36
|
+
if (seedCount === "invalid") return `collections[${index}].seedCount must be a non-negative integer`;
|
|
37
|
+
return {
|
|
38
|
+
fieldCount,
|
|
39
|
+
seedCount
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function parseEntry(value, index, registryName) {
|
|
43
|
+
if (!isRecord(value)) return `collections[${index}] is not an object`;
|
|
44
|
+
const entryId = pickString(value, "id");
|
|
45
|
+
const author = pickString(value, "author");
|
|
46
|
+
const slug = pickString(value, "slug");
|
|
47
|
+
const title = pickString(value, "title");
|
|
48
|
+
const version = pickString(value, "version");
|
|
49
|
+
const path = pickString(value, "path");
|
|
50
|
+
const contentSha = pickString(value, "contentSha");
|
|
51
|
+
if (!entryId || !author || !slug || !title || !version || !path || !contentSha) return `collections[${index}] is missing a required string field (id/author/slug/title/version/path/contentSha)`;
|
|
52
|
+
const mismatch = identityError(entryId, author, slug, path, index);
|
|
53
|
+
if (mismatch) return mismatch;
|
|
54
|
+
const counts = validatedCounts(value, index);
|
|
55
|
+
if (typeof counts === "string") return counts;
|
|
56
|
+
return {
|
|
57
|
+
id: entryId,
|
|
58
|
+
author,
|
|
59
|
+
slug,
|
|
60
|
+
title,
|
|
61
|
+
version,
|
|
62
|
+
path,
|
|
63
|
+
contentSha,
|
|
64
|
+
icon: pickString(value, "icon") ?? "",
|
|
65
|
+
description: pickString(value, "description") ?? "",
|
|
66
|
+
license: pickString(value, "license") ?? "",
|
|
67
|
+
tags: asStringArray(value.tags),
|
|
68
|
+
views: asStringArray(value.views),
|
|
69
|
+
fieldCount: counts.fieldCount,
|
|
70
|
+
seedCount: counts.seedCount,
|
|
71
|
+
hasSeed: pickBoolean(value, "hasSeed") ?? false,
|
|
72
|
+
screenshot: pickString(value, "screenshot"),
|
|
73
|
+
registryName
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Parse an index payload. `registryName` labels every entry produced — the
|
|
77
|
+
* caller passes "official" for the canonical registry or the user's config
|
|
78
|
+
* entry name. The label is needed downstream so import / preview can pick the
|
|
79
|
+
* right rawBase for per-collection files. */
|
|
80
|
+
function parseRegistryIndex(value, registryName) {
|
|
81
|
+
if (!isRecord(value)) return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: "index is not an object"
|
|
84
|
+
};
|
|
85
|
+
if (value.schemaVersion !== SUPPORTED_SCHEMA_VERSION) return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: `unsupported schemaVersion (expected ${SUPPORTED_SCHEMA_VERSION})`
|
|
88
|
+
};
|
|
89
|
+
const registry = pickString(value, "registry");
|
|
90
|
+
const generatedAt = pickString(value, "generatedAt");
|
|
91
|
+
if (!registry || !generatedAt) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: "index missing registry/generatedAt"
|
|
94
|
+
};
|
|
95
|
+
if (!Array.isArray(value.collections)) return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: "index.collections must be an array"
|
|
98
|
+
};
|
|
99
|
+
const parsed = value.collections.map((entry, idx) => parseEntry(entry, idx, registryName));
|
|
100
|
+
const firstError = parsed.find((entry) => typeof entry === "string");
|
|
101
|
+
if (firstError !== void 0) return {
|
|
102
|
+
ok: false,
|
|
103
|
+
error: firstError
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
index: {
|
|
108
|
+
schemaVersion: SUPPORTED_SCHEMA_VERSION,
|
|
109
|
+
registry,
|
|
110
|
+
generatedAt,
|
|
111
|
+
collections: parsed.filter((entry) => typeof entry !== "string")
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/collection/registry/types.ts
|
|
117
|
+
/** Reserved name for the official registry. The client always synthesizes one
|
|
118
|
+
* entry under this name; user config that re-uses it is rejected. */
|
|
119
|
+
var OFFICIAL_REGISTRY_NAME = "official";
|
|
120
|
+
//#endregion
|
|
121
|
+
Object.defineProperty(exports, "OFFICIAL_REGISTRY_NAME", {
|
|
122
|
+
enumerable: true,
|
|
123
|
+
get: function() {
|
|
124
|
+
return OFFICIAL_REGISTRY_NAME;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
Object.defineProperty(exports, "isRecord", {
|
|
128
|
+
enumerable: true,
|
|
129
|
+
get: function() {
|
|
130
|
+
return isRecord;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
Object.defineProperty(exports, "parseRegistryIndex", {
|
|
134
|
+
enumerable: true,
|
|
135
|
+
get: function() {
|
|
136
|
+
return parseRegistryIndex;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
//# sourceMappingURL=types-CNqkLT4M.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types-CNqkLT4M.cjs","names":[],"sources":["../src/collection/registry/guards.ts","../src/collection/registry/registryIndex.ts","../src/collection/registry/types.ts"],"sourcesContent":["/** Narrow `unknown` to a plain object (not null, not array). */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n","// Parse + validate the curated collection registry's published index.json.\n// Pure — no I/O. The caller fetches the JSON and hands the parsed value here.\n// Mirrors the registry repo's schema/index.schema.json contract (schemaVersion 1):\n// receptron/mulmoclaude-collections.\n//\n// The index is host-fetched convenience data for the Discover catalog, not a\n// trust boundary: a collection's actual files are re-validated on import.\n\nimport { isRecord } from \"./guards.js\";\n\nexport interface RegistryEntry {\n /** `<author>/<slug>` — identity inside its source registry. NOT globally\n * unique when multiple registries are configured — pair with `registryName`\n * for a global key. */\n id: string;\n author: string;\n slug: string;\n title: string;\n icon: string;\n description: string;\n version: string;\n tags: string[];\n license: string;\n fieldCount: number;\n /** Custom view labels. */\n views: string[];\n hasSeed: boolean;\n seedCount: number;\n /** repo-relative path, omitted when absent. */\n screenshot?: string;\n /** repo-relative collection dir. */\n path: string;\n /** Stable bundle hash for update detection. */\n contentSha: string;\n /** Label of the source registry. Set by the client (not by the parser) when\n * the index is fetched, so the import / preview path can resolve the right\n * rawBase. `\"official\"` for receptron/mulmoclaude-collections; otherwise\n * the user's config entry name. */\n registryName: string;\n}\n\nexport interface RegistryIndex {\n schemaVersion: number;\n generatedAt: string;\n registry: string;\n collections: RegistryEntry[];\n}\n\nexport type ParseResult = { ok: true; index: RegistryIndex } | { ok: false; error: string };\n\nconst SUPPORTED_SCHEMA_VERSION = 1;\n\nconst pickString = (rec: Record<string, unknown>, key: string): string | undefined => {\n const value = rec[key];\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n};\n\n// Count fields (fieldCount/seedCount) must be non-negative integers. A present\n// but negative/fractional value is rejected rather than served to the UI; an\n// absent value defaults to 0.\nfunction validatedCount(rec: Record<string, unknown>, key: string): number | \"invalid\" {\n const value = rec[key];\n if (value === undefined) return 0;\n return typeof value === \"number\" && Number.isInteger(value) && value >= 0 ? value : \"invalid\";\n}\n\nconst pickBoolean = (rec: Record<string, unknown>, key: string): boolean | undefined => {\n const value = rec[key];\n return typeof value === \"boolean\" ? value : undefined;\n};\n\nconst asStringArray = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === \"string\") : []);\n\n// Identifier allowlists (mirror the registry's meta.schema.json). Crucially these\n// exclude `.`, `/`, `\\`, and `..`, so a poisoned author/slug can never make\n// collectionFileUrl() escape the collections/<author>/<slug> subtree — even if the\n// entry is internally consistent. Bounded quantifiers (no ReDoS).\nconst AUTHOR_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;\nconst SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;\n\n// author/slug must be allowlisted identifiers, and id/path must agree with them, so a\n// lookup by author+slug can never fetch files from a different (or out-of-tree)\n// directory than requested (defense against a poisoned index).\nfunction identityError(entryId: string, author: string, slug: string, dirPath: string, index: number): string | null {\n if (!AUTHOR_RE.test(author)) return `collections[${index}].author has an invalid format`;\n if (!SLUG_RE.test(slug)) return `collections[${index}].slug has an invalid format`;\n if (entryId !== `${author}/${slug}`) return `collections[${index}].id must equal \"${author}/${slug}\"`;\n if (dirPath !== `collections/${author}/${slug}`) return `collections[${index}].path must equal \"collections/${author}/${slug}\"`;\n return null;\n}\n\nfunction validatedCounts(value: Record<string, unknown>, index: number): { fieldCount: number; seedCount: number } | string {\n const fieldCount = validatedCount(value, \"fieldCount\");\n const seedCount = validatedCount(value, \"seedCount\");\n if (fieldCount === \"invalid\") return `collections[${index}].fieldCount must be a non-negative integer`;\n if (seedCount === \"invalid\") return `collections[${index}].seedCount must be a non-negative integer`;\n return { fieldCount, seedCount };\n}\n\nfunction parseEntry(value: unknown, index: number, registryName: string): RegistryEntry | string {\n if (!isRecord(value)) return `collections[${index}] is not an object`;\n const entryId = pickString(value, \"id\");\n const author = pickString(value, \"author\");\n const slug = pickString(value, \"slug\");\n const title = pickString(value, \"title\");\n const version = pickString(value, \"version\");\n const path = pickString(value, \"path\");\n const contentSha = pickString(value, \"contentSha\");\n if (!entryId || !author || !slug || !title || !version || !path || !contentSha) {\n return `collections[${index}] is missing a required string field (id/author/slug/title/version/path/contentSha)`;\n }\n const mismatch = identityError(entryId, author, slug, path, index);\n if (mismatch) return mismatch;\n const counts = validatedCounts(value, index);\n if (typeof counts === \"string\") return counts;\n return {\n id: entryId,\n author,\n slug,\n title,\n version,\n path,\n contentSha,\n icon: pickString(value, \"icon\") ?? \"\",\n description: pickString(value, \"description\") ?? \"\",\n license: pickString(value, \"license\") ?? \"\",\n tags: asStringArray(value.tags),\n views: asStringArray(value.views),\n fieldCount: counts.fieldCount,\n seedCount: counts.seedCount,\n hasSeed: pickBoolean(value, \"hasSeed\") ?? false,\n screenshot: pickString(value, \"screenshot\"),\n registryName,\n };\n}\n\n/** Parse an index payload. `registryName` labels every entry produced — the\n * caller passes \"official\" for the canonical registry or the user's config\n * entry name. The label is needed downstream so import / preview can pick the\n * right rawBase for per-collection files. */\nexport function parseRegistryIndex(value: unknown, registryName: string): ParseResult {\n if (!isRecord(value)) return { ok: false, error: \"index is not an object\" };\n if (value.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {\n return { ok: false, error: `unsupported schemaVersion (expected ${SUPPORTED_SCHEMA_VERSION})` };\n }\n const registry = pickString(value, \"registry\");\n const generatedAt = pickString(value, \"generatedAt\");\n if (!registry || !generatedAt) return { ok: false, error: \"index missing registry/generatedAt\" };\n if (!Array.isArray(value.collections)) return { ok: false, error: \"index.collections must be an array\" };\n\n const parsed = value.collections.map((entry, idx) => parseEntry(entry, idx, registryName));\n const firstError = parsed.find((entry): entry is string => typeof entry === \"string\");\n if (firstError !== undefined) return { ok: false, error: firstError };\n const collections = parsed.filter((entry): entry is RegistryEntry => typeof entry !== \"string\");\n return { ok: true, index: { schemaVersion: SUPPORTED_SCHEMA_VERSION, registry, generatedAt, collections } };\n}\n","// Isomorphic contract shared by the registry engine, the host routes, and the\n// collection plugin's Discover UI. No I/O — types + a reserved-name constant.\n\nimport type { RegistryEntry } from \"./registryIndex.js\";\n\nexport interface RegistryConfigEntry {\n /** Short label shown on Discover cards + used as the routing key. */\n name: string;\n /** Absolute HTTPS URL of the registry's index.json. */\n indexUrl: string;\n /** Absolute HTTPS base for per-collection files (no trailing slash). */\n rawBaseUrl: string;\n}\n\n/** Reserved name for the official registry. The client always synthesizes one\n * entry under this name; user config that re-uses it is rejected. */\nexport const OFFICIAL_REGISTRY_NAME = \"official\";\n\n/** Per-registry summary in the merged Discover response. */\nexport interface RegistrySummary {\n name: string;\n /** `ok` = fresh, `stale` = served from cache because the upstream failed,\n * `failed` = no cache to fall back to (the entries contribution is 0). */\n status: \"ok\" | \"stale\" | \"failed\";\n generatedAt: string | null;\n error: string | null;\n entryCount: number;\n}\n\n/** `GET …collectionsRegistry.list` — the Discover catalog merged across every\n * configured registry. */\nexport interface RegistryListResponse {\n registries: RegistrySummary[];\n /** Convenience flag: true iff any single registry's contribution was stale. */\n stale: boolean;\n collections: RegistryEntry[];\n}\n\n/** `POST …collectionsRegistry.import` — install result. */\nexport interface RegistryImportResponse {\n localSlug: string;\n updated: boolean;\n seedWritten: number;\n seedSkipped: boolean;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.cjs","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.cjs","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/done/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":";;AAQA,IAAM,oBAA4C;CAChD,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,SAAS;CACT,IAAI;CACJ,IAAI;AACN;AAEA,SAAgB,wBAAwB,QAAwB;CAC9D,OAAO,kBAAkB,WAAW;AACtC;AAKA,IAAM,aAAa;AACnB,IAAM,aAAa;AACnB,IAAM,iBAAiB;AACvB,IAAM,sBAAsB;AAC5B,IAAM,uBAAuB;AAE7B,SAAS,mBAAuC;CAC9C,MAAM,aAAa;EAAC;EAA0B;EAAc;CAAW;CACvE,IAAI,OAAO,kBAAkB,aAAa,OAAO,KAAA;CACjD,OAAO,WAAW,MAAM,SAAS,cAAc,gBAAgB,IAAI,CAAC;AACtE;AAEA,SAAS,cAAc,MAA6B;CAClD,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,SAAS,IAAI,WAAW;EAC9B,OAAO,eAAe,QAAQ,OAAO,OAAO,MAAM,CAAC;EACnD,OAAO,gBAAgB,OAAO,OAAO,yBAAS,IAAI,MAAM,mBAAmB,CAAC;EAC5E,OAAO,cAAc,IAAI;CAC3B,CAAC;AACH;AAEA,SAAS,WAAW,QAA8B;CAChD,IAAI,MAAM;CACV,KAAK,MAAM,UAAU,QAAQ,OAAO,SAAS;CAC7C,OAAO,KAAK,KAAK,MAAM,OAAO,MAAM;AACtC;AAEA,SAAS,UAAU,KAAsB;CACvC,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAkCA,SAAgB,mBAAmB,WAAkC,UAAwB,WAAgD;CAC3I,IAAI,YAAY;CAChB,IAAI,YAAY;CAChB,IAAI,eAAe;CAEnB,SAAS,OAAa;EACpB,UAAU,UAAU;GAAE;GAAW;GAAW;EAAa,CAAC;CAC5D;CACA,SAAS,aAAa,OAAsB;EAC1C,IAAI,cAAc,OAAO;GACvB,YAAY;GACZ,KAAK;EACP;CACF;CACA,SAAS,aAAa,OAAsB;EAC1C,IAAI,cAAc,OAAO;GACvB,YAAY;GACZ,KAAK;EACP;CACF;CAEA,IAAI,SAA6B;CACjC,IAAI,WAAgC;CACpC,IAAI,WAAgC;CACpC,IAAI,4BAAY,IAAI,aAAa,CAAC;CAClC,IAAI,gBAA+B;CACnC,IAAI,WAAiC;CACrC,IAAI,SAAiB,CAAC;CACtB,IAAI,WAAW;CACf,IAAI,mBAAmB;CACvB,IAAI,eAA8B;CAClC,IAAI,eAAe;CACnB,IAAI,UAAU;CACd,IAAI,QAAuB,QAAQ,QAAQ;CAC3C,IAAI,yBAAwC;CAG5C,IAAI,aAAa;CACjB,IAAI,oBAAoB;CAGxB,IAAI,gBAAgB;CAEpB,SAAS,WAAW,OAAqB;EACvC,WAAW;EACX,MAAM,OAAO,UAAU;EACvB,IAAI,iBAAiB,MAAM;GACzB,eAAe;GACf,KAAK;EACP;CACF;CAEA,SAAS,uBAA6B;EACpC,IAAI,2BAA2B,MAAM;GACnC,OAAO,cAAc,sBAAsB;GAC3C,yBAAyB;EAC3B;CACF;CAEA,eAAe,sBAAqC;EAClD,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,UAAU,UAAU;EACrC,QAAQ;GACN,aAAa,KAAK;GAClB,qBAAqB;GACrB;EACF;EACA,aAAa,OAAO,KAAK;EACzB,IAAI,OAAO;OACL,2BAA2B,MAC7B,yBAAyB,OAAO,kBAAkB;IAChD,oBAAyB;GAC3B,GAAG,oBAAoB;EAAA,OAGzB,qBAAqB;CAEzB;CAEA,eAAe,YAAY,MAAY,KAA4B;EACjE,IAAI,QAAQ,YAAY;EACxB,IAAI;GACF,MAAM,UAAU,MAAM,cAAc,IAAI;GACxC,MAAM,SAAS,MAAM,UAAU,WAAW,SAAS,SAAS,CAAC;GAC7D,IAAI,QAAQ,YAAY;GACxB,MAAM,OAAO,OAAO,KAAK,KAAK;GAC9B,IAAI,KAAK,WAAW,GAAG,UAAU,UAAU;QACtC,UAAU,aAAa,IAAI;EAClC,SAAS,KAAK;GAGZ,IAAI,QAAQ,YAAY,UAAU,UAAU,UAAU,GAAG,CAAC;EAC5D;CACF;CAIA,SAAS,QAAQ,MAAY,KAAmB;EAC9C,WAAW,CAAC;EACZ,QAAQ,MACL,WAAW,YAAY,MAAM,GAAG,CAAC,CAAC,CAClC,YAAY,KAAA,CAAS,CAAC,CACtB,cAAc,WAAW,EAAE,CAAC;CACjC;CAEA,SAAS,gBAAwB;EAC/B,OAAO,SAAS,MAAM,GAAG,CAAC,CAAC,MAAM;CACnC;CAEA,SAAS,gBAAsB;EAC7B,MAAM,YAAY;EAClB,MAAM,MAAM;EACZ,MAAM,OAAO,IAAI,KAAK,QAAQ,EAAE,MAAM,cAAc,EAAE,CAAC;EACvD,IAAI,WAAW,cAAc;EAC7B,IAAI,aAAa,KAAK,OAAO,KAAK,QAAQ,YAAY,QAAQ,MAAM,GAAG;CACzE;CAEA,SAAS,gBAAsB;EAC7B,IAAI,CAAC,QAAQ;EACb,SAAS,CAAC;EACV,mBAAmB;EACnB,eAAe;EACf,eAAe,KAAK,IAAI;EACxB,oBAAoB;EACpB,WAAW,IAAI,cAAc,QAAQ,EAAE,SAAS,CAAC;EACjD,SAAS,mBAAmB,UAAU;GACpC,IAAI,MAAM,KAAK,OAAO,GAAG,OAAO,KAAK,MAAM,IAAI;EACjD;EACA,SAAS,SAAS;EAClB,SAAS,MAAM;CACjB;CAEA,SAAS,aAAmB;EAC1B,IAAI,YAAY,SAAS,UAAU,aAAa,SAAS,KAAK;CAChE;CAEA,SAAS,cAAoB;EAC3B,IAAI,CAAC,UAAU;EACf,SAAS,uBAAuB,SAAS;EACzC,MAAM,MAAM,WAAW,SAAS;EAChC,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,MAAM,YAAY;GACpB,mBAAmB;GACnB,eAAe;EACjB,OAAO,IAAI;OACL,iBAAiB,MAAM,eAAe;QACrC,IAAI,MAAM,eAAe,YAAY,WAAW;EAAA;EAEvD,IAAI,oBAAoB,MAAM,eAAe,gBAAgB,WAAW;CAC1E;CAEA,eAAe,QAA0B;EAGvC,IAAI,iBAAiB,WAAW,OAAO;EACvC,gBAAgB;EAChB,MAAM,WAAW;EACjB,IAAI;GACF,WAAW,iBAAiB,KAAK;GACjC,IAAI,CAAC,YAAY,CAAC,UAAU,cAAc,cAAc;IACtD,UAAU,UAAU,aAAa;IACjC,OAAO;GACT;GACA,IAAI;GACJ,IAAI;IACF,WAAW,MAAM,UAAU,aAAa,aAAa,EAAE,OAAO,KAAK,CAAC;GACtE,QAAQ;IACN,UAAU,UAAU,mBAAmB;IACvC,OAAO;GACT;GAGA,IAAI,aAAa,YAAY;IAC3B,SAAS,UAAU,CAAC,CAAC,SAAS,UAAU,MAAM,KAAK,CAAC;IACpD,OAAO;GACT;GACA,SAAS;GACT,WAAW,IAAI,aAAa;GAC5B,WAAW,SAAS,eAAe;GACnC,SAAS,UAAU;GACnB,YAAY,IAAI,aAAa,SAAS,OAAO;GAC7C,SAAS,wBAAwB,MAAM,CAAC,CAAC,QAAQ,QAAQ;GACzD,aAAa,IAAI;GACjB,cAAc;GACd,gBAAgB,OAAO,YAAY,aAAa,mBAAmB;GACnE,OAAO;EACT,UAAU;GACR,gBAAgB;EAClB;CACF;CAEA,SAAS,OAAa;EAGpB,cAAc;EACd,aAAa,KAAK;EAClB,IAAI,kBAAkB,MAAM;GAC1B,OAAO,cAAc,aAAa;GAClC,gBAAgB;EAClB;EACA,IAAI,YAAY,SAAS,UAAU,aAAa,SAAS,KAAK;EAC9D,WAAW;EACX,IAAI,UAAU;GACZ,SAAS,MAAM,CAAC,CAAC,YAAY,KAAA,CAAS;GACtC,WAAW;EACb;EACA,WAAW;EACX,QAAQ,UAAU,CAAC,CAAC,SAAS,UAAU,MAAM,KAAK,CAAC;EACnD,SAAS;CACX;CAEA,SAAS,UAAgB;EACvB,qBAAqB;EACrB,KAAK;CACP;CAEA,OAAO;EAAE;EAAqB;EAAO;EAAM;CAAQ;AACrD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":";AAQA,IAAM,oBAA4C;CAChD,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,SAAS;CACT,IAAI;CACJ,IAAI;AACN;AAEA,SAAgB,wBAAwB,QAAwB;CAC9D,OAAO,kBAAkB,WAAW;AACtC;AAKA,IAAM,aAAa;AACnB,IAAM,aAAa;AACnB,IAAM,iBAAiB;AACvB,IAAM,sBAAsB;AAC5B,IAAM,uBAAuB;AAE7B,SAAS,mBAAuC;CAC9C,MAAM,aAAa;EAAC;EAA0B;EAAc;CAAW;CACvE,IAAI,OAAO,kBAAkB,aAAa,OAAO,KAAA;CACjD,OAAO,WAAW,MAAM,SAAS,cAAc,gBAAgB,IAAI,CAAC;AACtE;AAEA,SAAS,cAAc,MAA6B;CAClD,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,SAAS,IAAI,WAAW;EAC9B,OAAO,eAAe,QAAQ,OAAO,OAAO,MAAM,CAAC;EACnD,OAAO,gBAAgB,OAAO,OAAO,yBAAS,IAAI,MAAM,mBAAmB,CAAC;EAC5E,OAAO,cAAc,IAAI;CAC3B,CAAC;AACH;AAEA,SAAS,WAAW,QAA8B;CAChD,IAAI,MAAM;CACV,KAAK,MAAM,UAAU,QAAQ,OAAO,SAAS;CAC7C,OAAO,KAAK,KAAK,MAAM,OAAO,MAAM;AACtC;AAEA,SAAS,UAAU,KAAsB;CACvC,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAkCA,SAAgB,mBAAmB,WAAkC,UAAwB,WAAgD;CAC3I,IAAI,YAAY;CAChB,IAAI,YAAY;CAChB,IAAI,eAAe;CAEnB,SAAS,OAAa;EACpB,UAAU,UAAU;GAAE;GAAW;GAAW;EAAa,CAAC;CAC5D;CACA,SAAS,aAAa,OAAsB;EAC1C,IAAI,cAAc,OAAO;GACvB,YAAY;GACZ,KAAK;EACP;CACF;CACA,SAAS,aAAa,OAAsB;EAC1C,IAAI,cAAc,OAAO;GACvB,YAAY;GACZ,KAAK;EACP;CACF;CAEA,IAAI,SAA6B;CACjC,IAAI,WAAgC;CACpC,IAAI,WAAgC;CACpC,IAAI,4BAAY,IAAI,aAAa,CAAC;CAClC,IAAI,gBAA+B;CACnC,IAAI,WAAiC;CACrC,IAAI,SAAiB,CAAC;CACtB,IAAI,WAAW;CACf,IAAI,mBAAmB;CACvB,IAAI,eAA8B;CAClC,IAAI,eAAe;CACnB,IAAI,UAAU;CACd,IAAI,QAAuB,QAAQ,QAAQ;CAC3C,IAAI,yBAAwC;CAG5C,IAAI,aAAa;CACjB,IAAI,oBAAoB;CAGxB,IAAI,gBAAgB;CAEpB,SAAS,WAAW,OAAqB;EACvC,WAAW;EACX,MAAM,OAAO,UAAU;EACvB,IAAI,iBAAiB,MAAM;GACzB,eAAe;GACf,KAAK;EACP;CACF;CAEA,SAAS,uBAA6B;EACpC,IAAI,2BAA2B,MAAM;GACnC,OAAO,cAAc,sBAAsB;GAC3C,yBAAyB;EAC3B;CACF;CAEA,eAAe,sBAAqC;EAClD,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,UAAU,UAAU;EACrC,QAAQ;GACN,aAAa,KAAK;GAClB,qBAAqB;GACrB;EACF;EACA,aAAa,OAAO,KAAK;EACzB,IAAI,OAAO;OACL,2BAA2B,MAC7B,yBAAyB,OAAO,kBAAkB;IAChD,oBAAyB;GAC3B,GAAG,oBAAoB;EAAA,OAGzB,qBAAqB;CAEzB;CAEA,eAAe,YAAY,MAAY,KAA4B;EACjE,IAAI,QAAQ,YAAY;EACxB,IAAI;GACF,MAAM,UAAU,MAAM,cAAc,IAAI;GACxC,MAAM,SAAS,MAAM,UAAU,WAAW,SAAS,SAAS,CAAC;GAC7D,IAAI,QAAQ,YAAY;GACxB,MAAM,OAAO,OAAO,KAAK,KAAK;GAC9B,IAAI,KAAK,WAAW,GAAG,UAAU,UAAU;QACtC,UAAU,aAAa,IAAI;EAClC,SAAS,KAAK;GAGZ,IAAI,QAAQ,YAAY,UAAU,UAAU,UAAU,GAAG,CAAC;EAC5D;CACF;CAIA,SAAS,QAAQ,MAAY,KAAmB;EAC9C,WAAW,CAAC;EACZ,QAAQ,MACL,WAAW,YAAY,MAAM,GAAG,CAAC,CAAC,CAClC,YAAY,KAAA,CAAS,CAAC,CACtB,cAAc,WAAW,EAAE,CAAC;CACjC;CAEA,SAAS,gBAAwB;EAC/B,OAAO,SAAS,MAAM,GAAG,CAAC,CAAC,MAAM;CACnC;CAEA,SAAS,gBAAsB;EAC7B,MAAM,YAAY;EAClB,MAAM,MAAM;EACZ,MAAM,OAAO,IAAI,KAAK,QAAQ,EAAE,MAAM,cAAc,EAAE,CAAC;EACvD,IAAI,WAAW,cAAc;EAC7B,IAAI,aAAa,KAAK,OAAO,KAAK,QAAQ,YAAY,QAAQ,MAAM,GAAG;CACzE;CAEA,SAAS,gBAAsB;EAC7B,IAAI,CAAC,QAAQ;EACb,SAAS,CAAC;EACV,mBAAmB;EACnB,eAAe;EACf,eAAe,KAAK,IAAI;EACxB,oBAAoB;EACpB,WAAW,IAAI,cAAc,QAAQ,EAAE,SAAS,CAAC;EACjD,SAAS,mBAAmB,UAAU;GACpC,IAAI,MAAM,KAAK,OAAO,GAAG,OAAO,KAAK,MAAM,IAAI;EACjD;EACA,SAAS,SAAS;EAClB,SAAS,MAAM;CACjB;CAEA,SAAS,aAAmB;EAC1B,IAAI,YAAY,SAAS,UAAU,aAAa,SAAS,KAAK;CAChE;CAEA,SAAS,cAAoB;EAC3B,IAAI,CAAC,UAAU;EACf,SAAS,uBAAuB,SAAS;EACzC,MAAM,MAAM,WAAW,SAAS;EAChC,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,MAAM,YAAY;GACpB,mBAAmB;GACnB,eAAe;EACjB,OAAO,IAAI;OACL,iBAAiB,MAAM,eAAe;QACrC,IAAI,MAAM,eAAe,YAAY,WAAW;EAAA;EAEvD,IAAI,oBAAoB,MAAM,eAAe,gBAAgB,WAAW;CAC1E;CAEA,eAAe,QAA0B;EAGvC,IAAI,iBAAiB,WAAW,OAAO;EACvC,gBAAgB;EAChB,MAAM,WAAW;EACjB,IAAI;GACF,WAAW,iBAAiB,KAAK;GACjC,IAAI,CAAC,YAAY,CAAC,UAAU,cAAc,cAAc;IACtD,UAAU,UAAU,aAAa;IACjC,OAAO;GACT;GACA,IAAI;GACJ,IAAI;IACF,WAAW,MAAM,UAAU,aAAa,aAAa,EAAE,OAAO,KAAK,CAAC;GACtE,QAAQ;IACN,UAAU,UAAU,mBAAmB;IACvC,OAAO;GACT;GAGA,IAAI,aAAa,YAAY;IAC3B,SAAS,UAAU,CAAC,CAAC,SAAS,UAAU,MAAM,KAAK,CAAC;IACpD,OAAO;GACT;GACA,SAAS;GACT,WAAW,IAAI,aAAa;GAC5B,WAAW,SAAS,eAAe;GACnC,SAAS,UAAU;GACnB,YAAY,IAAI,aAAa,SAAS,OAAO;GAC7C,SAAS,wBAAwB,MAAM,CAAC,CAAC,QAAQ,QAAQ;GACzD,aAAa,IAAI;GACjB,cAAc;GACd,gBAAgB,OAAO,YAAY,aAAa,mBAAmB;GACnE,OAAO;EACT,UAAU;GACR,gBAAgB;EAClB;CACF;CAEA,SAAS,OAAa;EAGpB,cAAc;EACd,aAAa,KAAK;EAClB,IAAI,kBAAkB,MAAM;GAC1B,OAAO,cAAc,aAAa;GAClC,gBAAgB;EAClB;EACA,IAAI,YAAY,SAAS,UAAU,aAAa,SAAS,KAAK;EAC9D,WAAW;EACX,IAAI,UAAU;GACZ,SAAS,MAAM,CAAC,CAAC,YAAY,KAAA,CAAS;GACtC,WAAW;EACb;EACA,WAAW;EACX,QAAQ,UAAU,CAAC,CAAC,SAAS,UAAU,MAAM,KAAK,CAAC;EACnD,SAAS;CACX;CAEA,SAAS,UAAgB;EACvB,qBAAqB;EACrB,KAAK;CACP;CAEA,OAAO;EAAE;EAAqB;EAAO;EAAM;CAAQ;AACrD"}
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/done/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/core",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./whisper/client
|
|
3
|
+
"version": "0.2.16",
|
|
4
|
+
"description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./whisper/client, ./workspace-setup/slug and ./translation/client entries. All host specifics are injected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./collection": {
|
|
@@ -22,6 +22,18 @@
|
|
|
22
22
|
"require": "./dist/collection/paths.cjs",
|
|
23
23
|
"default": "./dist/collection/paths.js"
|
|
24
24
|
},
|
|
25
|
+
"./collection/registry": {
|
|
26
|
+
"types": "./dist/collection/registry/index.d.ts",
|
|
27
|
+
"import": "./dist/collection/registry/index.js",
|
|
28
|
+
"require": "./dist/collection/registry/index.cjs",
|
|
29
|
+
"default": "./dist/collection/registry/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./collection/registry/server": {
|
|
32
|
+
"types": "./dist/collection/registry/server/index.d.ts",
|
|
33
|
+
"import": "./dist/collection/registry/server/index.js",
|
|
34
|
+
"require": "./dist/collection/registry/server/index.cjs",
|
|
35
|
+
"default": "./dist/collection/registry/server/index.js"
|
|
36
|
+
},
|
|
25
37
|
"./feeds": {
|
|
26
38
|
"types": "./dist/feeds/index.d.ts",
|
|
27
39
|
"import": "./dist/feeds/index.js",
|
|
@@ -82,6 +94,12 @@
|
|
|
82
94
|
"require": "./dist/whisper/client.cjs",
|
|
83
95
|
"default": "./dist/whisper/client.js"
|
|
84
96
|
},
|
|
97
|
+
"./translation/client": {
|
|
98
|
+
"types": "./dist/translation/client.d.ts",
|
|
99
|
+
"import": "./dist/translation/client.js",
|
|
100
|
+
"require": "./dist/translation/client.cjs",
|
|
101
|
+
"default": "./dist/translation/client.js"
|
|
102
|
+
},
|
|
85
103
|
"./workspace-setup": {
|
|
86
104
|
"types": "./dist/workspace-setup/index.d.ts",
|
|
87
105
|
"import": "./dist/workspace-setup/index.js",
|