@mulmoclaude/core 0.2.15 → 0.4.0

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.
Files changed (67) hide show
  1. package/dist/collection/registry/guards.d.ts +2 -0
  2. package/dist/collection/registry/index.cjs +4 -0
  3. package/dist/collection/registry/index.d.ts +2 -0
  4. package/dist/collection/registry/index.js +2 -0
  5. package/dist/collection/registry/registryIndex.d.ts +48 -0
  6. package/dist/collection/registry/server/client.d.ts +60 -0
  7. package/dist/collection/registry/server/collectionFiles.d.ts +51 -0
  8. package/dist/collection/registry/server/exportCollection.d.ts +29 -0
  9. package/dist/collection/registry/server/fetch.d.ts +8 -0
  10. package/dist/collection/registry/server/importCollection.d.ts +35 -0
  11. package/dist/collection/registry/server/importWriter.d.ts +30 -0
  12. package/dist/collection/registry/server/index.cjs +1110 -0
  13. package/dist/collection/registry/server/index.cjs.map +1 -0
  14. package/dist/collection/registry/server/index.d.ts +28 -0
  15. package/dist/collection/registry/server/index.js +1076 -0
  16. package/dist/collection/registry/server/index.js.map +1 -0
  17. package/dist/collection/registry/server/performExport.d.ts +6 -0
  18. package/dist/collection/registry/server/registriesConfig.d.ts +11 -0
  19. package/dist/collection/registry/server/skillDescription.d.ts +4 -0
  20. package/dist/collection/registry/types.d.ts +37 -0
  21. package/dist/collection/server/host.d.ts +7 -0
  22. package/dist/collection/server/index.cjs +1 -1
  23. package/dist/collection/server/index.js +1 -1
  24. package/dist/collection/server/util.d.ts +1 -0
  25. package/dist/collection-watchers/index.cjs +1 -1
  26. package/dist/collection-watchers/index.js +1 -1
  27. package/dist/feeds/server/index.cjs +1 -1
  28. package/dist/feeds/server/index.cjs.map +1 -1
  29. package/dist/feeds/server/index.js +1 -1
  30. package/dist/feeds/server/index.js.map +1 -1
  31. package/dist/notifier-ChpY0XrY.js.map +1 -1
  32. package/dist/notifier-bS8IEeLA.cjs.map +1 -1
  33. package/dist/{server-CRlmOBVQ.js → server-DRoqc8dL.js} +8 -2
  34. package/dist/server-DRoqc8dL.js.map +1 -0
  35. package/dist/{server-BjXvqGrE.cjs → server-DoDXibCq.cjs} +31 -1
  36. package/dist/server-DoDXibCq.cjs.map +1 -0
  37. package/dist/slug-CdN-pQX1.js +37 -0
  38. package/dist/slug-CdN-pQX1.js.map +1 -0
  39. package/dist/slug-DbwORN9z.cjs +48 -0
  40. package/dist/slug-DbwORN9z.cjs.map +1 -0
  41. package/dist/types-BKVZrtyc.js +123 -0
  42. package/dist/types-BKVZrtyc.js.map +1 -0
  43. package/dist/types-CNqkLT4M.cjs +140 -0
  44. package/dist/types-CNqkLT4M.cjs.map +1 -0
  45. package/dist/whisper/client.cjs.map +1 -1
  46. package/dist/whisper/client.js.map +1 -1
  47. package/dist/wiki/graph.d.ts +33 -0
  48. package/dist/wiki/index-parse.d.ts +39 -0
  49. package/dist/wiki/index.cjs +493 -0
  50. package/dist/wiki/index.cjs.map +1 -0
  51. package/dist/wiki/index.d.ts +7 -0
  52. package/dist/wiki/index.js +465 -0
  53. package/dist/wiki/index.js.map +1 -0
  54. package/dist/wiki/link.d.ts +41 -0
  55. package/dist/wiki/lint.d.ts +26 -0
  56. package/dist/wiki/render.d.ts +19 -0
  57. package/dist/wiki/route.d.ts +32 -0
  58. package/dist/wiki/server/index.cjs +30 -0
  59. package/dist/wiki/server/index.cjs.map +1 -0
  60. package/dist/wiki/server/index.d.ts +1 -0
  61. package/dist/wiki/server/index.js +27 -0
  62. package/dist/wiki/server/index.js.map +1 -0
  63. package/dist/wiki/server/paths.d.ts +11 -0
  64. package/dist/wiki/slug.d.ts +24 -0
  65. package/package.json +25 -1
  66. package/dist/server-BjXvqGrE.cjs.map +0 -1
  67. 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":"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"}
@@ -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":"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"}
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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"}
@@ -0,0 +1,33 @@
1
+ import { WikiPageEntry } from './index-parse.js';
2
+ export interface WikiGraphNode {
3
+ slug: string;
4
+ title: string;
5
+ }
6
+ export interface WikiGraphEdge {
7
+ from: string;
8
+ to: string;
9
+ }
10
+ export interface WikiGraph {
11
+ nodes: WikiGraphNode[];
12
+ edges: WikiGraphEdge[];
13
+ }
14
+ /** One page's raw body plus its canonical (file-derived) slug. */
15
+ export interface WikiPageContent {
16
+ slug: string;
17
+ content: string;
18
+ }
19
+ /** Resolve a raw `[[link]]` target to an existing page slug, or null.
20
+ * Mirrors the route resolver's strategy: slugify first, then fall
21
+ * back to matching an index entry title so non-ASCII targets like
22
+ * `[[さくらインターネット]]` resolve to their ASCII file slug. */
23
+ export declare function resolveLinkTarget(target: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string | null;
24
+ /** Resolved, deduped outgoing slugs for one page body. Self-links and
25
+ * links to non-existent pages are dropped (the latter are already a
26
+ * lint "broken link", not a graph edge). */
27
+ export declare function pageOutgoingSlugs(fromSlug: string, content: string, fileSlugs: ReadonlySet<string>, slugByTitle: ReadonlyMap<string, string>): string[];
28
+ /** Build the full page→page graph. Nodes are the existing page files
29
+ * (titled from index.md, falling back to the slug for un-indexed
30
+ * pages); edges are the resolved `[[links]]`, deduped per (from,to). */
31
+ export declare function buildWikiGraph(pages: readonly WikiPageContent[], entries: readonly WikiPageEntry[]): WikiGraph;
32
+ /** Pages that link TO `slug` (incoming edges), deduped, as nodes. */
33
+ export declare function incomingLinks(graph: WikiGraph, slug: string): WikiGraphNode[];
@@ -0,0 +1,39 @@
1
+ export interface WikiPageEntry {
2
+ title: string;
3
+ slug: string;
4
+ description: string;
5
+ tags: string[];
6
+ }
7
+ export declare const BULLET_LINK_PATTERN: RegExp;
8
+ export declare const BULLET_WIKI_LINK_PATTERN: RegExp;
9
+ /** Extract `#tag` tokens from a bullet description, returning the
10
+ * stripped description and a sorted, deduped, lowercased tag list.
11
+ * Only matches at word boundaries so mid-word `#` (e.g. anchor
12
+ * URLs) is left alone. */
13
+ export declare function extractHashTags(text: string): {
14
+ description: string;
15
+ tags: string[];
16
+ };
17
+ /** Split a table Tags cell — tolerates comma, whitespace, or `#`
18
+ * prefixes. Empty cell yields an empty list. */
19
+ export declare function parseTagsCell(cell: string): string[];
20
+ /** Map header cell names → column indices, case- and whitespace-
21
+ * tolerant. Used by the row parser to locate the Tags column (and
22
+ * any other named column) without assuming a fixed position, so
23
+ * older 3- and 4-column tables keep working. */
24
+ export declare function buildTableColumnMap(headerRow: string): Map<string, number>;
25
+ /** Extract the slug segment from a bullet link's href. Accepts the
26
+ * canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`
27
+ * — the three forms produced by different historical writers of
28
+ * index.md. Returns "" for hrefs that don't look like a wiki page
29
+ * reference (e.g. `https://example.com`) so the caller can fall
30
+ * back to title-based slugification. */
31
+ export declare function extractSlugFromBulletHref(rawHref: string): string;
32
+ /** Parse entries from index.md. Supports three formats:
33
+ * 1. Table: `| slug | Title | Summary | (Tags) |`
34
+ * 2. Bullet link: `- [Title](pages/slug.md) — description`
35
+ * 3. Wiki link: `- [[Title]] — description`
36
+ *
37
+ * Returns entries in source order. Any unrecognised line is
38
+ * silently skipped — index.md is freeform markdown otherwise. */
39
+ export declare function parseIndexEntries(content: string): WikiPageEntry[];