@mulmoclaude/core 1.14.0 → 1.14.1
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/atomic-C_7YpMiM.cjs.map +1 -1
- package/dist/atomic-DPpdrJzO.js.map +1 -1
- package/dist/{calendarGrid-DivcNKkX.js → calendarGrid-Csy2rpjp.js} +58 -28
- package/dist/{calendarGrid-DivcNKkX.js.map → calendarGrid-Csy2rpjp.js.map} +1 -1
- package/dist/{calendarGrid-BQYqOCnF.cjs → calendarGrid-p8K-7cuB.cjs} +58 -28
- package/dist/{calendarGrid-BQYqOCnF.cjs.map → calendarGrid-p8K-7cuB.cjs.map} +1 -1
- package/dist/collection/core/actionVisible.d.ts +3 -3
- package/dist/collection/core/completion.d.ts +5 -5
- package/dist/collection/index.cjs +5 -8
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +5 -8
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/registryIndex.d.ts +1 -1
- package/dist/collection/registry/server/index.cjs +14 -12
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +14 -12
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/discovery.d.ts +2 -2
- package/dist/collection/server/index.cjs +3 -3
- package/dist/collection/server/index.js +3 -3
- package/dist/collection/server/io.d.ts +3 -3
- package/dist/collection/server/manageTool.d.ts +3 -3
- package/dist/collection/server/store.d.ts +3 -3
- package/dist/collection/server/validate.d.ts +1 -1
- package/dist/collection-watchers/index.cjs +5 -5
- package/dist/collection-watchers/index.js +5 -5
- package/dist/{discovery-D6p4ITNR.js → discovery-BSedPW8A.js} +14 -6
- package/dist/discovery-BSedPW8A.js.map +1 -0
- package/dist/{discovery-CRDx6TS4.cjs → discovery-DSLgRQhN.cjs} +14 -6
- package/dist/discovery-DSLgRQhN.cjs.map +1 -0
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/host.d.ts +2 -2
- package/dist/feeds/server/index.cjs +11 -11
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +11 -11
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/feeds/server/refreshResult.d.ts +2 -2
- package/dist/files/index.cjs +3 -2
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.js +3 -2
- package/dist/files/index.js.map +1 -1
- package/dist/google/calendar.d.ts +24 -24
- package/dist/google/collectionSync.d.ts +2 -2
- package/dist/google/index.cjs +3 -3
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +3 -3
- package/dist/google/index.js.map +1 -1
- package/dist/{graph-DjE1Az1C.cjs → graph-BZJxiv1E.cjs} +10 -12
- package/dist/graph-BZJxiv1E.cjs.map +1 -0
- package/dist/{graph-BcwqiSpR.js → graph-Ce7xsDTt.js} +10 -12
- package/dist/graph-Ce7xsDTt.js.map +1 -0
- package/dist/{ingestTypes-C7IJSqv4.cjs → ingestTypes-CPR-h-OZ.cjs} +2 -2
- package/dist/{ingestTypes-C7IJSqv4.cjs.map → ingestTypes-CPR-h-OZ.cjs.map} +1 -1
- package/dist/{ingestTypes-DmtDLRmt.js → ingestTypes-F2IJhOLJ.js} +2 -2
- package/dist/{ingestTypes-DmtDLRmt.js.map → ingestTypes-F2IJhOLJ.js.map} +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/notifier/types.d.ts +4 -4
- package/dist/{notifier-Wc4M24Bi.cjs → notifier-CKFVVwDP.cjs} +35 -24
- package/dist/notifier-CKFVVwDP.cjs.map +1 -0
- package/dist/{notifier-Dxh8mEFh.js → notifier-CQD8NljP.js} +35 -24
- package/dist/notifier-CQD8NljP.js.map +1 -0
- package/dist/{promptSafety-B52GvaJc.cjs → promptSafety-DUZ5BiW5.cjs} +11 -7
- package/dist/promptSafety-DUZ5BiW5.cjs.map +1 -0
- package/dist/{promptSafety-DJgaDjM4.js → promptSafety-DgHMF4Pz.js} +11 -7
- package/dist/promptSafety-DgHMF4Pz.js.map +1 -0
- package/dist/remote-host/server/hostRunner.d.ts +7 -2
- package/dist/remote-host/server/index.cjs +12 -9
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +13 -10
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +1 -1
- package/dist/remote-view/index.js.map +1 -1
- package/dist/scheduler/adapter.d.ts +4 -4
- package/dist/scheduler/index.cjs +34 -2
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +34 -2
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +2 -2
- package/dist/{server-D7_oRbTJ.cjs → server-1c6gB372.cjs} +12 -8
- package/dist/server-1c6gB372.cjs.map +1 -0
- package/dist/{server-D6EUf3BF.js → server-Dv_L7l0y.js} +12 -8
- package/dist/server-Dv_L7l0y.js.map +1 -0
- package/dist/skill-bridge/index.cjs +3 -4
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/skill-bridge/index.js +3 -4
- package/dist/skill-bridge/index.js.map +1 -1
- package/dist/types-BKVZrtyc.js.map +1 -1
- package/dist/types-CNqkLT4M.cjs.map +1 -1
- package/dist/utils/fetch.cjs.map +1 -1
- package/dist/utils/fetch.d.ts +1 -1
- package/dist/utils/fetch.js.map +1 -1
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.d.ts +3 -3
- package/dist/whisper/client.js.map +1 -1
- package/dist/wiki/index.cjs +2 -2
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +2 -2
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/server/index.cjs +15 -12
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +15 -12
- package/dist/wiki/server/index.js.map +1 -1
- package/package.json +6 -6
- package/dist/discovery-CRDx6TS4.cjs.map +0 -1
- package/dist/discovery-D6p4ITNR.js.map +0 -1
- package/dist/graph-BcwqiSpR.js.map +0 -1
- package/dist/graph-DjE1Az1C.cjs.map +0 -1
- package/dist/notifier-Dxh8mEFh.js.map +0 -1
- package/dist/notifier-Wc4M24Bi.cjs.map +0 -1
- package/dist/promptSafety-B52GvaJc.cjs.map +0 -1
- package/dist/promptSafety-DJgaDjM4.js.map +0 -1
- package/dist/server-D6EUf3BF.js.map +0 -1
- package/dist/server-D7_oRbTJ.cjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/skill-bridge/index.ts"],"sourcesContent":["// @mulmoclaude/core/skill-bridge — the skill staging→active mirror RULE, shared by\n// MulmoClaude and MulmoTerminal so the two hosts can't drift on what crosses the\n// `.claude/` permission gate.\n//\n// The agent writes skill drafts under `data/skills/<slug>/` (a plain data dir, no\n// `.claude/` permission special-case). This package decides which writes are\n// allowlisted and mirrors them 1:1 into `.claude/skills/<slug>/` so Claude CLI's\n// skill discovery + the collection engine pick them up. It also mirrors a\n// `rm -rf data/skills/<slug>` delete.\n//\n// What crosses — a FIXED ALLOWLIST, not the whole dir:\n// SKILL.md the skill body Claude CLI reads\n// schema.json the collection definition (a \"collection skill\" ships one)\n// templates/<path> action templates a schema's `actions` reference — exactly the\n// set the schema validator accepts (`isSafeActionTemplatePath`)\n// Everything else (README.md, assets/, arbitrary nesting) stays staging-side.\n//\n// Pure rule + fs ops, parameterized by `workspaceRoot` (passed in, not read from a\n// host module) so either host can call it. Each host wires this into its own hook /\n// PostToolUse path and triggers its own config refresh afterward.\nimport path from \"node:path\";\nimport { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { isSafeActionTemplatePath } from \"../collection/server/templatePath\";\n\nconst DATA_SKILLS_DIR = path.join(\"data\", \"skills\");\nconst CLAUDE_SKILLS_DIR = path.join(\".claude\", \"skills\");\nconst SKILL_FILENAME = \"SKILL.md\";\nconst SCHEMA_FILENAME = \"schema.json\";\n\n// Slugs follow Claude Code's skill-name convention: lowercase ASCII letters/digits\n// with single-hyphen separators. Strict so a typo / traversal never reaches the dest.\n// eslint-disable-next-line security/detect-unsafe-regex -- input is a basename slice ≤64 chars; bounded backtracking.\nconst SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;\n\n// `rm -rf data/skills/<slug>` matcher. `match[1]` is the flag run (must contain a\n// recursive flag); `match[2]` is the slug. Wildcards / the parent dir / non-recursive\n// `rm` are intentionally NOT matched (a non-recursive rm can't delete the dir anyway,\n// so mirroring would desync the trees).\n// eslint-disable-next-line security/detect-unsafe-regex -- single-line captured Bash command; bounded.\nconst RM_RE = /^\\s*rm\\s+((?:-[a-zA-Z]+\\s+)+)['\"]?data\\/skills\\/([a-z0-9-]+)\\/?['\"]?\\s*$/;\nconst RECURSIVE_FLAG_RE = /[rR]/;\n\n/** A staging file cleared to cross the `.claude/` gate. */\nexport interface BridgeTarget {\n /** The skill slug (the dir directly under data/skills/). */\n slug: string;\n /** Path segments BELOW the slug dir, e.g. `[\"SKILL.md\"]`, `[\"templates\",\"x.md\"]`.\n * Used verbatim for both source and destination so the mirror is 1:1. */\n relSegments: string[];\n}\n\n/** `<workspaceRoot>/data/skills/<slug>` — the staging dir for a skill. */\nexport function dataSkillDir(workspaceRoot: string, slug: string): string {\n return path.join(workspaceRoot, DATA_SKILLS_DIR, slug);\n}\n\n/** `<workspaceRoot>/.claude/skills/<slug>` — the active dir Claude CLI discovers. */\nexport function claudeSkillDir(workspaceRoot: string, slug: string): string {\n return path.join(workspaceRoot, CLAUDE_SKILLS_DIR, slug);\n}\n\n// Allowlist a path below the slug dir: SKILL.md / schema.json at the top, or a safe\n// `templates/<path>` (the same predicate the schema validator uses, so what a schema\n// can declare is exactly what crosses). Everything else → false.\nfunction isAllowlisted(relSegments: string[]): boolean {\n if (relSegments.length === 1) {\n return relSegments[0] === SKILL_FILENAME || relSegments[0] === SCHEMA_FILENAME;\n }\n return isSafeActionTemplatePath(relSegments.join(\"/\"));\n}\n\n/** Resolve a Write/Edit path to the staging file it mirrors, or null when it isn't a\n * bridged file (not under `data/skills/<slug>/`, bad slug, or off the allowlist). */\nexport function bridgeTargetFromDataPath(workspaceRoot: string, filePath: string): BridgeTarget | null {\n const staging = path.join(workspaceRoot, DATA_SKILLS_DIR);\n const rel = path.relative(staging, filePath);\n if (!rel || rel.startsWith(\"..\") || path.isAbsolute(rel)) return null;\n const
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/skill-bridge/index.ts"],"sourcesContent":["// @mulmoclaude/core/skill-bridge — the skill staging→active mirror RULE, shared by\n// MulmoClaude and MulmoTerminal so the two hosts can't drift on what crosses the\n// `.claude/` permission gate.\n//\n// The agent writes skill drafts under `data/skills/<slug>/` (a plain data dir, no\n// `.claude/` permission special-case). This package decides which writes are\n// allowlisted and mirrors them 1:1 into `.claude/skills/<slug>/` so Claude CLI's\n// skill discovery + the collection engine pick them up. It also mirrors a\n// `rm -rf data/skills/<slug>` delete.\n//\n// What crosses — a FIXED ALLOWLIST, not the whole dir:\n// SKILL.md the skill body Claude CLI reads\n// schema.json the collection definition (a \"collection skill\" ships one)\n// templates/<path> action templates a schema's `actions` reference — exactly the\n// set the schema validator accepts (`isSafeActionTemplatePath`)\n// Everything else (README.md, assets/, arbitrary nesting) stays staging-side.\n//\n// Pure rule + fs ops, parameterized by `workspaceRoot` (passed in, not read from a\n// host module) so either host can call it. Each host wires this into its own hook /\n// PostToolUse path and triggers its own config refresh afterward.\nimport path from \"node:path\";\nimport { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { isSafeActionTemplatePath } from \"../collection/server/templatePath\";\n\nconst DATA_SKILLS_DIR = path.join(\"data\", \"skills\");\nconst CLAUDE_SKILLS_DIR = path.join(\".claude\", \"skills\");\nconst SKILL_FILENAME = \"SKILL.md\";\nconst SCHEMA_FILENAME = \"schema.json\";\n\n// Slugs follow Claude Code's skill-name convention: lowercase ASCII letters/digits\n// with single-hyphen separators. Strict so a typo / traversal never reaches the dest.\n// eslint-disable-next-line security/detect-unsafe-regex -- input is a basename slice ≤64 chars; bounded backtracking.\nconst SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;\n\n// `rm -rf data/skills/<slug>` matcher. `match[1]` is the flag run (must contain a\n// recursive flag); `match[2]` is the slug. Wildcards / the parent dir / non-recursive\n// `rm` are intentionally NOT matched (a non-recursive rm can't delete the dir anyway,\n// so mirroring would desync the trees).\n// eslint-disable-next-line security/detect-unsafe-regex -- single-line captured Bash command; bounded.\nconst RM_RE = /^\\s*rm\\s+((?:-[a-zA-Z]+\\s+)+)['\"]?data\\/skills\\/([a-z0-9-]+)\\/?['\"]?\\s*$/;\nconst RECURSIVE_FLAG_RE = /[rR]/;\n\n/** A staging file cleared to cross the `.claude/` gate. */\nexport interface BridgeTarget {\n /** The skill slug (the dir directly under data/skills/). */\n slug: string;\n /** Path segments BELOW the slug dir, e.g. `[\"SKILL.md\"]`, `[\"templates\",\"x.md\"]`.\n * Used verbatim for both source and destination so the mirror is 1:1. */\n relSegments: string[];\n}\n\n/** `<workspaceRoot>/data/skills/<slug>` — the staging dir for a skill. */\nexport function dataSkillDir(workspaceRoot: string, slug: string): string {\n return path.join(workspaceRoot, DATA_SKILLS_DIR, slug);\n}\n\n/** `<workspaceRoot>/.claude/skills/<slug>` — the active dir Claude CLI discovers. */\nexport function claudeSkillDir(workspaceRoot: string, slug: string): string {\n return path.join(workspaceRoot, CLAUDE_SKILLS_DIR, slug);\n}\n\n// Allowlist a path below the slug dir: SKILL.md / schema.json at the top, or a safe\n// `templates/<path>` (the same predicate the schema validator uses, so what a schema\n// can declare is exactly what crosses). Everything else → false.\nfunction isAllowlisted(relSegments: string[]): boolean {\n if (relSegments.length === 1) {\n return relSegments[0] === SKILL_FILENAME || relSegments[0] === SCHEMA_FILENAME;\n }\n return isSafeActionTemplatePath(relSegments.join(\"/\"));\n}\n\n/** Resolve a Write/Edit path to the staging file it mirrors, or null when it isn't a\n * bridged file (not under `data/skills/<slug>/`, bad slug, or off the allowlist). */\nexport function bridgeTargetFromDataPath(workspaceRoot: string, filePath: string): BridgeTarget | null {\n const staging = path.join(workspaceRoot, DATA_SKILLS_DIR);\n const rel = path.relative(staging, filePath);\n if (!rel || rel.startsWith(\"..\") || path.isAbsolute(rel)) return null;\n const [slug, ...relSegments] = rel.split(path.sep);\n if (!slug || relSegments.length === 0) return null; // need at least `<slug>/<file>`\n if (!SLUG_RE.test(slug)) return null;\n if (!isAllowlisted(relSegments)) return null;\n return { slug, relSegments };\n}\n\n/** Extract the slug from a Bash `rm -rf data/skills/<slug>` command, or null on any\n * mismatch (wildcards, paths outside staging, non-recursive `rm`). */\nexport function slugFromRmCommand(command: string): string | null {\n const match = RM_RE.exec(command);\n if (!match) return null;\n // Both groups are mandatory in RM_RE; the \"\" defaults fail both tests below,\n // i.e. they land on the same `null` a non-matching command already gets.\n const [, flags = \"\", slug = \"\"] = match;\n if (!RECURSIVE_FLAG_RE.test(flags)) return null;\n return SLUG_RE.test(slug) ? slug : null;\n}\n\n/** Atomic mirror of one allowlisted staging file → its active path: write a tmp file\n * in the dest dir, then rename onto the canonical path (atomic on POSIX; both paths\n * share a filesystem). A killed mid-write leaves a harmless tmp and the previous\n * SKILL.md intact. Returns the resolved src/dest for logging. */\nexport function mirrorSkillWrite(workspaceRoot: string, target: BridgeTarget): { src: string; dest: string } {\n const { slug, relSegments } = target;\n const src = path.join(dataSkillDir(workspaceRoot, slug), ...relSegments);\n const content = readFileSync(src, \"utf-8\");\n const dest = path.join(claudeSkillDir(workspaceRoot, slug), ...relSegments);\n const destDir = path.dirname(dest);\n mkdirSync(destDir, { recursive: true }); // covers templates/ + the slug dir on first install\n const tmp = path.join(destDir, `.${path.basename(dest)}.${process.pid}.tmp`);\n writeFileSync(tmp, content, \"utf-8\");\n renameSync(tmp, dest);\n return { src, dest };\n}\n\n/** Remove the active skill dir (mirrors a staging `rm -rf`). Returns the dest. */\nexport function mirrorSkillDelete(workspaceRoot: string, slug: string): { dest: string } {\n const dest = claudeSkillDir(workspaceRoot, slug);\n rmSync(dest, { recursive: true, force: true });\n return { dest };\n}\n"],"mappings":";;;;AAwBA,IAAM,kBAAkB,KAAK,KAAK,QAAQ,QAAQ;AAClD,IAAM,oBAAoB,KAAK,KAAK,WAAW,QAAQ;AACvD,IAAM,iBAAiB;AACvB,IAAM,kBAAkB;AAKxB,IAAM,UAAU;AAOhB,IAAM,QAAQ;AACd,IAAM,oBAAoB;;AAY1B,SAAgB,aAAa,eAAuB,MAAsB;CACxE,OAAO,KAAK,KAAK,eAAe,iBAAiB,IAAI;AACvD;;AAGA,SAAgB,eAAe,eAAuB,MAAsB;CAC1E,OAAO,KAAK,KAAK,eAAe,mBAAmB,IAAI;AACzD;AAKA,SAAS,cAAc,aAAgC;CACrD,IAAI,YAAY,WAAW,GACzB,OAAO,YAAY,OAAO,kBAAkB,YAAY,OAAO;CAEjE,OAAO,yBAAyB,YAAY,KAAK,GAAG,CAAC;AACvD;;;AAIA,SAAgB,yBAAyB,eAAuB,UAAuC;CACrG,MAAM,UAAU,KAAK,KAAK,eAAe,eAAe;CACxD,MAAM,MAAM,KAAK,SAAS,SAAS,QAAQ;CAC3C,IAAI,CAAC,OAAO,IAAI,WAAW,IAAI,KAAK,KAAK,WAAW,GAAG,GAAG,OAAO;CACjE,MAAM,CAAC,MAAM,GAAG,eAAe,IAAI,MAAM,KAAK,GAAG;CACjD,IAAI,CAAC,QAAQ,YAAY,WAAW,GAAG,OAAO;CAC9C,IAAI,CAAC,QAAQ,KAAK,IAAI,GAAG,OAAO;CAChC,IAAI,CAAC,cAAc,WAAW,GAAG,OAAO;CACxC,OAAO;EAAE;EAAM;CAAY;AAC7B;;;AAIA,SAAgB,kBAAkB,SAAgC;CAChE,MAAM,QAAQ,MAAM,KAAK,OAAO;CAChC,IAAI,CAAC,OAAO,OAAO;CAGnB,MAAM,GAAG,QAAQ,IAAI,OAAO,MAAM;CAClC,IAAI,CAAC,kBAAkB,KAAK,KAAK,GAAG,OAAO;CAC3C,OAAO,QAAQ,KAAK,IAAI,IAAI,OAAO;AACrC;;;;;AAMA,SAAgB,iBAAiB,eAAuB,QAAqD;CAC3G,MAAM,EAAE,MAAM,gBAAgB;CAC9B,MAAM,MAAM,KAAK,KAAK,aAAa,eAAe,IAAI,GAAG,GAAG,WAAW;CACvE,MAAM,UAAU,aAAa,KAAK,OAAO;CACzC,MAAM,OAAO,KAAK,KAAK,eAAe,eAAe,IAAI,GAAG,GAAG,WAAW;CAC1E,MAAM,UAAU,KAAK,QAAQ,IAAI;CACjC,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;CACtC,MAAM,MAAM,KAAK,KAAK,SAAS,IAAI,KAAK,SAAS,IAAI,EAAE,GAAG,QAAQ,IAAI,KAAK;CAC3E,cAAc,KAAK,SAAS,OAAO;CACnC,WAAW,KAAK,IAAI;CACpB,OAAO;EAAE;EAAK;CAAK;AACrB;;AAGA,SAAgB,kBAAkB,eAAuB,MAAgC;CACvF,MAAM,OAAO,eAAe,eAAe,IAAI;CAC/C,OAAO,MAAM;EAAE,WAAW;EAAM,OAAO;CAAK,CAAC;CAC7C,OAAO,EAAE,KAAK;AAChB"}
|
|
@@ -1 +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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"}
|
|
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 | undefined;\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":"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
|
+
{"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 | undefined;\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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"}
|
package/dist/utils/fetch.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch.cjs","names":[],"sources":["../../src/utils/fetch.ts"],"sourcesContent":["// Canonical `fetch` with a finite timeout for the whole monorepo. The host\n// (`server/utils/fetch.ts`), the collection registry engine and the Google\n// engine all re-export / import this one copy — before #2398 the same helper\n// existed three times, so #2221's caller-signal-composition fix could only land\n// in one of them. Server-only surface (`@mulmoclaude/core/fetch`).\n\nconst ONE_SECOND_MS = 1_000;\n\n// 10 s is long enough for a healthy round-trip (localhost or a healthy upstream)\n// but short enough that a stuck peer returns an error well before any client-side\n// tool-call timeout fires. Callers talking to slow external APIs override via\n// `timeoutMs`.\nexport const DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;\n\n// `Parameters<typeof fetch>[1]` avoids referencing the ambient `RequestInit`\n// type, which ESLint's `no-undef` rule trips over in the server config. The\n// resulting type is identical to `RequestInit & { timeoutMs?: number }`.\nexport type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & { timeoutMs?: number };\n\n/**\n * `fetch` with a finite timeout. Rejects with a `TimeoutError` once `timeoutMs`\n * elapses. Composes with a caller-supplied `signal` so external cancellation\n * still works (#2221 — do not overwrite the caller's signal).\n */\nexport async function fetchWithTimeout(url: string | URL, init: FetchWithTimeoutInit = {}): Promise<Response> {\n const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;\n\n // Surface an already-aborted caller signal before we touch the network at all,\n // so callers get a deterministic rejection and no fetch side-effects.\n if (callerSignal?.aborted) {\n throw callerSignal.reason ?? new DOMException(\"Aborted\", \"AbortError\");\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => {\n controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, \"TimeoutError\"));\n }, timeoutMs);\n\n const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);\n\n try {\n return await fetch(url, { ...rest, signal: controller.signal });\n } finally {\n clearTimeout(timer);\n unsubscribeCaller?.();\n }\n}\n\n// Propagate aborts from a caller-supplied signal into our internal controller.\n// Returns a disposer so the listener is removed on normal completion — otherwise\n// a long-lived caller signal would leak listeners across many fetches.\nfunction bridgeExternalSignal(external: AbortSignal | null | undefined, controller: AbortController): (() => void) | null {\n if (!external) return null;\n const onAbort = () => controller.abort(external.reason);\n external.addEventListener(\"abort\", onAbort, { once: true });\n return () => external.removeEventListener(\"abort\", onAbort);\n}\n"],"mappings":";AAYA,IAAa,2BAA2B,KAAK;;;;;;AAY7C,eAAsB,iBAAiB,KAAmB,OAA6B,CAAC,GAAsB;CAC5G,MAAM,EAAE,YAAY,0BAA0B,QAAQ,cAAc,GAAG,SAAS;CAIhF,IAAI,cAAc,SAChB,MAAM,aAAa,UAAU,IAAI,aAAa,WAAW,YAAY;CAGvE,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB;EAC7B,WAAW,MAAM,IAAI,aAAa,yBAAyB,UAAU,KAAK,cAAc,CAAC;CAC3F,GAAG,SAAS;CAEZ,MAAM,oBAAoB,qBAAqB,cAAc,UAAU;CAEvE,IAAI;EACF,OAAO,MAAM,MAAM,KAAK;GAAE,GAAG;GAAM,QAAQ,WAAW;EAAO,CAAC;CAChE,UAAU;EACR,aAAa,KAAK;EAClB,oBAAoB;CACtB;AACF;AAKA,SAAS,qBAAqB,UAA0C,YAAkD;CACxH,IAAI,CAAC,UAAU,OAAO;CACtB,MAAM,gBAAgB,WAAW,MAAM,SAAS,MAAM;CACtD,SAAS,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;CAC1D,aAAa,SAAS,oBAAoB,SAAS,OAAO;AAC5D"}
|
|
1
|
+
{"version":3,"file":"fetch.cjs","names":[],"sources":["../../src/utils/fetch.ts"],"sourcesContent":["// Canonical `fetch` with a finite timeout for the whole monorepo. The host\n// (`server/utils/fetch.ts`), the collection registry engine and the Google\n// engine all re-export / import this one copy — before #2398 the same helper\n// existed three times, so #2221's caller-signal-composition fix could only land\n// in one of them. Server-only surface (`@mulmoclaude/core/fetch`).\n\nconst ONE_SECOND_MS = 1_000;\n\n// 10 s is long enough for a healthy round-trip (localhost or a healthy upstream)\n// but short enough that a stuck peer returns an error well before any client-side\n// tool-call timeout fires. Callers talking to slow external APIs override via\n// `timeoutMs`.\nexport const DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;\n\n// `Parameters<typeof fetch>[1]` avoids referencing the ambient `RequestInit`\n// type, which ESLint's `no-undef` rule trips over in the server config. The\n// resulting type is identical to `RequestInit & { timeoutMs?: number }`.\nexport type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & { timeoutMs?: number | undefined };\n\n/**\n * `fetch` with a finite timeout. Rejects with a `TimeoutError` once `timeoutMs`\n * elapses. Composes with a caller-supplied `signal` so external cancellation\n * still works (#2221 — do not overwrite the caller's signal).\n */\nexport async function fetchWithTimeout(url: string | URL, init: FetchWithTimeoutInit = {}): Promise<Response> {\n const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;\n\n // Surface an already-aborted caller signal before we touch the network at all,\n // so callers get a deterministic rejection and no fetch side-effects.\n if (callerSignal?.aborted) {\n throw callerSignal.reason ?? new DOMException(\"Aborted\", \"AbortError\");\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => {\n controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, \"TimeoutError\"));\n }, timeoutMs);\n\n const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);\n\n try {\n return await fetch(url, { ...rest, signal: controller.signal });\n } finally {\n clearTimeout(timer);\n unsubscribeCaller?.();\n }\n}\n\n// Propagate aborts from a caller-supplied signal into our internal controller.\n// Returns a disposer so the listener is removed on normal completion — otherwise\n// a long-lived caller signal would leak listeners across many fetches.\nfunction bridgeExternalSignal(external: AbortSignal | null | undefined, controller: AbortController): (() => void) | null {\n if (!external) return null;\n const onAbort = () => controller.abort(external.reason);\n external.addEventListener(\"abort\", onAbort, { once: true });\n return () => external.removeEventListener(\"abort\", onAbort);\n}\n"],"mappings":";AAYA,IAAa,2BAA2B,KAAK;;;;;;AAY7C,eAAsB,iBAAiB,KAAmB,OAA6B,CAAC,GAAsB;CAC5G,MAAM,EAAE,YAAY,0BAA0B,QAAQ,cAAc,GAAG,SAAS;CAIhF,IAAI,cAAc,SAChB,MAAM,aAAa,UAAU,IAAI,aAAa,WAAW,YAAY;CAGvE,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB;EAC7B,WAAW,MAAM,IAAI,aAAa,yBAAyB,UAAU,KAAK,cAAc,CAAC;CAC3F,GAAG,SAAS;CAEZ,MAAM,oBAAoB,qBAAqB,cAAc,UAAU;CAEvE,IAAI;EACF,OAAO,MAAM,MAAM,KAAK;GAAE,GAAG;GAAM,QAAQ,WAAW;EAAO,CAAC;CAChE,UAAU;EACR,aAAa,KAAK;EAClB,oBAAoB;CACtB;AACF;AAKA,SAAS,qBAAqB,UAA0C,YAAkD;CACxH,IAAI,CAAC,UAAU,OAAO;CACtB,MAAM,gBAAgB,WAAW,MAAM,SAAS,MAAM;CACtD,SAAS,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;CAC1D,aAAa,SAAS,oBAAoB,SAAS,OAAO;AAC5D"}
|
package/dist/utils/fetch.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const DEFAULT_FETCH_TIMEOUT_MS: number;
|
|
2
2
|
export type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & {
|
|
3
|
-
timeoutMs?: number;
|
|
3
|
+
timeoutMs?: number | undefined;
|
|
4
4
|
};
|
|
5
5
|
/**
|
|
6
6
|
* `fetch` with a finite timeout. Rejects with a `TimeoutError` once `timeoutMs`
|
package/dist/utils/fetch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch.js","names":[],"sources":["../../src/utils/fetch.ts"],"sourcesContent":["// Canonical `fetch` with a finite timeout for the whole monorepo. The host\n// (`server/utils/fetch.ts`), the collection registry engine and the Google\n// engine all re-export / import this one copy — before #2398 the same helper\n// existed three times, so #2221's caller-signal-composition fix could only land\n// in one of them. Server-only surface (`@mulmoclaude/core/fetch`).\n\nconst ONE_SECOND_MS = 1_000;\n\n// 10 s is long enough for a healthy round-trip (localhost or a healthy upstream)\n// but short enough that a stuck peer returns an error well before any client-side\n// tool-call timeout fires. Callers talking to slow external APIs override via\n// `timeoutMs`.\nexport const DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;\n\n// `Parameters<typeof fetch>[1]` avoids referencing the ambient `RequestInit`\n// type, which ESLint's `no-undef` rule trips over in the server config. The\n// resulting type is identical to `RequestInit & { timeoutMs?: number }`.\nexport type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & { timeoutMs?: number };\n\n/**\n * `fetch` with a finite timeout. Rejects with a `TimeoutError` once `timeoutMs`\n * elapses. Composes with a caller-supplied `signal` so external cancellation\n * still works (#2221 — do not overwrite the caller's signal).\n */\nexport async function fetchWithTimeout(url: string | URL, init: FetchWithTimeoutInit = {}): Promise<Response> {\n const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;\n\n // Surface an already-aborted caller signal before we touch the network at all,\n // so callers get a deterministic rejection and no fetch side-effects.\n if (callerSignal?.aborted) {\n throw callerSignal.reason ?? new DOMException(\"Aborted\", \"AbortError\");\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => {\n controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, \"TimeoutError\"));\n }, timeoutMs);\n\n const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);\n\n try {\n return await fetch(url, { ...rest, signal: controller.signal });\n } finally {\n clearTimeout(timer);\n unsubscribeCaller?.();\n }\n}\n\n// Propagate aborts from a caller-supplied signal into our internal controller.\n// Returns a disposer so the listener is removed on normal completion — otherwise\n// a long-lived caller signal would leak listeners across many fetches.\nfunction bridgeExternalSignal(external: AbortSignal | null | undefined, controller: AbortController): (() => void) | null {\n if (!external) return null;\n const onAbort = () => controller.abort(external.reason);\n external.addEventListener(\"abort\", onAbort, { once: true });\n return () => external.removeEventListener(\"abort\", onAbort);\n}\n"],"mappings":"AAYA,IAAa,2BAA2B,KAAK;;;;;;AAY7C,eAAsB,iBAAiB,KAAmB,OAA6B,CAAC,GAAsB;CAC5G,MAAM,EAAE,YAAY,0BAA0B,QAAQ,cAAc,GAAG,SAAS;CAIhF,IAAI,cAAc,SAChB,MAAM,aAAa,UAAU,IAAI,aAAa,WAAW,YAAY;CAGvE,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB;EAC7B,WAAW,MAAM,IAAI,aAAa,yBAAyB,UAAU,KAAK,cAAc,CAAC;CAC3F,GAAG,SAAS;CAEZ,MAAM,oBAAoB,qBAAqB,cAAc,UAAU;CAEvE,IAAI;EACF,OAAO,MAAM,MAAM,KAAK;GAAE,GAAG;GAAM,QAAQ,WAAW;EAAO,CAAC;CAChE,UAAU;EACR,aAAa,KAAK;EAClB,oBAAoB;CACtB;AACF;AAKA,SAAS,qBAAqB,UAA0C,YAAkD;CACxH,IAAI,CAAC,UAAU,OAAO;CACtB,MAAM,gBAAgB,WAAW,MAAM,SAAS,MAAM;CACtD,SAAS,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;CAC1D,aAAa,SAAS,oBAAoB,SAAS,OAAO;AAC5D"}
|
|
1
|
+
{"version":3,"file":"fetch.js","names":[],"sources":["../../src/utils/fetch.ts"],"sourcesContent":["// Canonical `fetch` with a finite timeout for the whole monorepo. The host\n// (`server/utils/fetch.ts`), the collection registry engine and the Google\n// engine all re-export / import this one copy — before #2398 the same helper\n// existed three times, so #2221's caller-signal-composition fix could only land\n// in one of them. Server-only surface (`@mulmoclaude/core/fetch`).\n\nconst ONE_SECOND_MS = 1_000;\n\n// 10 s is long enough for a healthy round-trip (localhost or a healthy upstream)\n// but short enough that a stuck peer returns an error well before any client-side\n// tool-call timeout fires. Callers talking to slow external APIs override via\n// `timeoutMs`.\nexport const DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;\n\n// `Parameters<typeof fetch>[1]` avoids referencing the ambient `RequestInit`\n// type, which ESLint's `no-undef` rule trips over in the server config. The\n// resulting type is identical to `RequestInit & { timeoutMs?: number }`.\nexport type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & { timeoutMs?: number | undefined };\n\n/**\n * `fetch` with a finite timeout. Rejects with a `TimeoutError` once `timeoutMs`\n * elapses. Composes with a caller-supplied `signal` so external cancellation\n * still works (#2221 — do not overwrite the caller's signal).\n */\nexport async function fetchWithTimeout(url: string | URL, init: FetchWithTimeoutInit = {}): Promise<Response> {\n const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;\n\n // Surface an already-aborted caller signal before we touch the network at all,\n // so callers get a deterministic rejection and no fetch side-effects.\n if (callerSignal?.aborted) {\n throw callerSignal.reason ?? new DOMException(\"Aborted\", \"AbortError\");\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => {\n controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, \"TimeoutError\"));\n }, timeoutMs);\n\n const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);\n\n try {\n return await fetch(url, { ...rest, signal: controller.signal });\n } finally {\n clearTimeout(timer);\n unsubscribeCaller?.();\n }\n}\n\n// Propagate aborts from a caller-supplied signal into our internal controller.\n// Returns a disposer so the listener is removed on normal completion — otherwise\n// a long-lived caller signal would leak listeners across many fetches.\nfunction bridgeExternalSignal(external: AbortSignal | null | undefined, controller: AbortController): (() => void) | null {\n if (!external) return null;\n const onAbort = () => controller.abort(external.reason);\n external.addEventListener(\"abort\", onAbort, { once: true });\n return () => external.removeEventListener(\"abort\", onAbort);\n}\n"],"mappings":"AAYA,IAAa,2BAA2B,KAAK;;;;;;AAY7C,eAAsB,iBAAiB,KAAmB,OAA6B,CAAC,GAAsB;CAC5G,MAAM,EAAE,YAAY,0BAA0B,QAAQ,cAAc,GAAG,SAAS;CAIhF,IAAI,cAAc,SAChB,MAAM,aAAa,UAAU,IAAI,aAAa,WAAW,YAAY;CAGvE,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,QAAQ,iBAAiB;EAC7B,WAAW,MAAM,IAAI,aAAa,yBAAyB,UAAU,KAAK,cAAc,CAAC;CAC3F,GAAG,SAAS;CAEZ,MAAM,oBAAoB,qBAAqB,cAAc,UAAU;CAEvE,IAAI;EACF,OAAO,MAAM,MAAM,KAAK;GAAE,GAAG;GAAM,QAAQ,WAAW;EAAO,CAAC;CAChE,UAAU;EACR,aAAa,KAAK;EAClB,oBAAoB;CACtB;AACF;AAKA,SAAS,qBAAqB,UAA0C,YAAkD;CACxH,IAAI,CAAC,UAAU,OAAO;CACtB,MAAM,gBAAgB,WAAW,MAAM,SAAS,MAAM;CACtD,SAAS,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;CAC1D,aAAa,SAAS,oBAAoB,SAAS,OAAO;AAC5D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.cjs","names":[],"sources":["../../src/whisper/client-helpers.ts","../../src/whisper/client.ts"],"sourcesContent":["// Pure, framework-neutral helpers for the browser voice-capture controller.\n// Kept separate from `client.ts` so the audio math and VAD state machine can be\n// unit-tested without a DOM / MediaRecorder / Web Audio environment.\n\nexport function 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\n/** The recorder mime can carry a codec suffix (`audio/webm;codecs=opus`); the\n * Blob container type is just the part before the `;`. */\nexport function containerTypeFromMime(mimeType: string): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n}\n\nexport interface VadState {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n}\n\nexport interface VadConfig {\n readonly speechRms: number;\n readonly silenceMs: number;\n readonly maxSegmentMs: number;\n}\n\nexport interface VadDecision {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n readonly cut: boolean;\n}\n\nfunction isSpeech(rms: number, config: VadConfig): boolean {\n return rms > config.speechRms;\n}\n\nfunction nextSilenceStart(state: VadState, rms: number, nowMs: number, config: VadConfig): number | null {\n if (isSpeech(rms, config)) return null;\n if (state.hasSpeech && state.silenceStart === null) return nowMs;\n return state.silenceStart;\n}\n\nfunction silenceExceeded(state: VadState, rms: number, nowMs: number, config: VadConfig): boolean {\n if (isSpeech(rms, config) || !state.hasSpeech || state.silenceStart === null) return false;\n return nowMs - state.silenceStart > config.silenceMs;\n}\n\n/** Advance the pause-detector one tick: fold the current RMS/time into the\n * segment state and decide whether the segment should be force-cut (either a\n * long-enough silence after speech, or the max-segment length reached). */\nexport function evaluateVad(state: VadState, segmentStartMs: number, rms: number, nowMs: number, config: VadConfig): VadDecision {\n const hasSpeech = isSpeech(rms, config) || state.hasSpeech;\n const silenceStart = nextSilenceStart(state, rms, nowMs, config);\n const maxCut = hasSpeech && nowMs - segmentStartMs > config.maxSegmentMs;\n return { hasSpeech, silenceStart, cut: silenceExceeded(state, rms, nowMs, config) || maxCut };\n}\n","// @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\nimport { computeRms, containerTypeFromMime, evaluateVad, type VadConfig } from \"./client-helpers.ts\";\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;\nconst VAD_CONFIG: VadConfig = { speechRms: SPEECH_RMS, silenceMs: SILENCE_MS, maxSegmentMs: MAX_SEGMENT_MS };\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 // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FileReader.result is `string | ArrayBuffer | null`; readAsDataURL always yields the string branch, and the rule cannot narrow it.\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 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 interface CaptureStateController {\n setAvailable: (value: boolean) => void;\n setListening: (value: boolean) => void;\n setPending: (delta: number) => void;\n isListening: () => boolean;\n}\n\n// Owns the three observable flags. Each setter emits only on an actual change so\n// the host's reactivity never churns on a no-op write. `pending` is a private\n// counter; `transcribing` is true exactly while at least one send is in flight.\nexport function createCaptureState(onState?: (state: VoiceCaptureState) => void): CaptureStateController {\n let available = false;\n let listening = false;\n let transcribing = false;\n let pending = 0;\n\n function emit(): void {\n 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 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 return { setAvailable, setListening, setPending, isListening: () => listening };\n}\n\nexport interface AvailabilityPoller {\n refresh: () => Promise<void>;\n stop: () => void;\n}\n\n// Owns the polling timer. `refresh` mirrors transport readiness into\n// `setAvailable`; while a model is downloading it self-schedules so `available`\n// flips as soon as the download finishes. A `getStatus` throw fails closed\n// (unavailable) and tears the timer down.\nexport function createAvailabilityPoller(transport: VoiceCaptureTransport, setAvailable: (value: boolean) => void): AvailabilityPoller {\n let availabilityPollHandle: number | null = null;\n\n function stop(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refresh(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stop();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refresh();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stop();\n }\n }\n\n return { refresh, stop };\n}\n\nexport interface SegmentQueueOptions {\n transport: VoiceCaptureTransport;\n language: () => string;\n callbacks: VoiceCaptureCallbacks;\n setPending: (delta: number) => void;\n // Read-only epoch seam: the queue never mutates the generation, it only reads\n // the parent's current value to drop segments captured under an older session.\n getGeneration: () => number;\n}\n\nexport interface SegmentQueue {\n enqueue: (blob: Blob, gen: number) => void;\n}\n\n// Owns the serialized send chain. Each blob is transcribed in capture order; the\n// generation is checked at entry and again after transcription so a segment\n// belonging to a session the user already left is dropped silently.\nexport function createSegmentQueue(options: SegmentQueueOptions): SegmentQueue {\n const { transport, language, callbacks, setPending, getGeneration } = options;\n let queue: Promise<void> = Promise.resolve();\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== getGeneration()) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== getGeneration()) 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 === getGeneration()) 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 return { enqueue };\n}\n\nexport interface AudioGraph {\n /** True between `attach()` and the next `teardown()`. */\n isAttached: () => boolean;\n /** Read the current PCM window into an internal buffer and return it.\n * Callers must not mutate the returned array. */\n sample: () => Float32Array;\n /** Live mic stream — the outer needs it to hand to MediaRecorder. */\n getStream: () => MediaStream | null;\n /** Attach a fresh mic stream: create AudioContext + AnalyserNode + VAD buffer,\n * connect the source. Called only by `bootstrapCapture` after the caller has\n * passed the stale-generation / permission checks. */\n attach: (stream: MediaStream) => void;\n /** Close the AudioContext, stop the mic tracks, null everything. Safe to\n * call before `attach()` or twice in a row. */\n teardown: () => void;\n}\n\n// Owns the browser-side audio pipeline: mic stream + AudioContext + AnalyserNode\n// + the reusable Float32 buffer VAD reads samples into. Separated from\n// `createVoiceCapture` so its four browser-API fields don't inflate the parent\n// factory's line count and so the setup/teardown ordering is in one place.\nfunction createAudioGraph(): AudioGraph {\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\n function attach(nextStream: MediaStream): void {\n stream = nextStream;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(nextStream).connect(analyser);\n }\n\n function sample(): Float32Array {\n analyser?.getFloatTimeDomainData(vadBuffer);\n return vadBuffer;\n }\n\n function teardown(): void {\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 return { isAttached: () => analyser !== null, sample, getStream: () => stream, attach, teardown };\n}\n\ninterface BootstrappedCapture {\n audioGraph: AudioGraph;\n mimeType: string;\n}\n\n// Acquire the mic + probe MediaRecorder support + wire an AudioGraph, returning\n// null (with `callbacks.onError` fired) on any recoverable failure. Deliberately\n// does NOT touch `state` or start the recorder — the caller commits the session\n// only after this resolves successfully, so a stale `generation` detected mid-\n// flight can drop the tracks and bail without partially publishing state.\nasync function bootstrapCapture(callbacks: VoiceCaptureCallbacks, generationAtStart: number, generationNow: () => number): Promise<BootstrappedCapture | null> {\n const mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return null;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return null;\n }\n if (generationAtStart !== generationNow()) {\n acquired.getTracks().forEach((track) => track.stop());\n return null;\n }\n const audioGraph = createAudioGraph();\n try {\n audioGraph.attach(acquired);\n } catch (err) {\n // teardown() is null-safe and closes any half-created AudioContext AND\n // stops the mic tracks — the pre-refactor code only stopped tracks and\n // leaked the context (codex/CodeRabbit findings on this PR).\n audioGraph.teardown();\n throw err;\n }\n return { audioGraph, mimeType };\n}\n\nexport interface RecorderSession {\n isRecording: () => boolean;\n hadSpeech: () => boolean;\n markSpeech: () => void;\n /** Start a new segment on `stream`, tagged with `gen`. Resets per-segment\n * state (chunks + hadSpeech) synchronously so a subsequent VAD tick sees\n * a fresh window. */\n startNext: (stream: MediaStream, mimeType: string, gen: number) => void;\n /** Force-close the current segment (silence exceeded or MAX_SEGMENT_MS). */\n cut: () => void;\n}\n\ninterface RecorderSessionDeps {\n /** Fires each time a segment finishes. The parent decides whether to\n * restart (via `session.startNext`) and whether to enqueue the blob. */\n onSegmentEnd: (blob: Blob, hadSpeech: boolean, gen: number) => void;\n}\n\n// Owns MediaRecorder + its per-segment scratchpad (chunks, hadSpeech, gen,\n// mimeType). The parent keeps VAD state and the global generation, and calls\n// `startNext` on both initial-start and post-onstop restart paths.\nfunction createRecorderSession(deps: RecorderSessionDeps): RecorderSession {\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let segmentHasSpeech = false;\n let sessionGen = 0;\n let sessionMimeType = \"\";\n\n function onStop(): void {\n // Snapshot BEFORE the parent's onSegmentEnd callback runs; `startNext`\n // (if the parent chooses to restart) will clear chunks synchronously.\n const hadSpeech = segmentHasSpeech;\n const gen = sessionGen;\n const blob = new Blob(chunks, { type: containerTypeFromMime(sessionMimeType) });\n deps.onSegmentEnd(blob, hadSpeech, gen);\n }\n\n function startNext(stream: MediaStream, mimeType: string, gen: number): void {\n chunks = [];\n segmentHasSpeech = false;\n sessionGen = gen;\n sessionMimeType = mimeType;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onStop;\n recorder.start();\n }\n\n return {\n isRecording: () => recorder?.state === \"recording\",\n hadSpeech: () => segmentHasSpeech,\n markSpeech: () => {\n segmentHasSpeech = true;\n },\n startNext,\n cut: () => {\n if (recorder?.state === \"recording\") recorder.stop();\n },\n };\n}\n\n// Zero-values used to seed a fresh `CaptureRuntime` at the top of every\n// `createVoiceCapture` call. Kept module-scope so the seed doesn't inflate the\n// factory's line count.\nconst INITIAL_RUNTIME: Readonly<CaptureRuntime> = {\n audioGraph: null,\n monitorHandle: null,\n mimeType: \"\",\n silenceStart: null,\n segmentStart: 0,\n generation: 0,\n startInFlight: false,\n};\n\n// Mutable capture-time state, kept as one object so module-scope helpers\n// (`stopCapture`, `monitorTick`) can be typed against one shape rather than\n// threading each field through their signatures.\ninterface CaptureRuntime {\n audioGraph: AudioGraph | null;\n monitorHandle: number | null;\n mimeType: string;\n silenceStart: number | null;\n segmentStart: number;\n /** Bumped on stop(); segments captured under an older value are dropped so a\n * late transcript never leaks across sessions. */\n generation: number;\n /** Single-flight guard on start() — true between entry and completion so a\n * second start can't race the first before `listening` flips. */\n startInFlight: boolean;\n}\n\n// Tear down the browser-side resources. `state.setListening(false)` MUST happen\n// BEFORE `session.cut()` so the parent's onSegmentEnd callback sees\n// isListening()===false and does NOT restart. The monitor interval MUST clear\n// BEFORE `audioGraph.teardown()` so a scheduled tick can't run against a\n// half-closed AudioContext.\nfunction stopCapture(runtime: CaptureRuntime, state: CaptureStateController, session: RecorderSession): void {\n runtime.generation += 1;\n state.setListening(false);\n if (runtime.monitorHandle !== null) {\n window.clearInterval(runtime.monitorHandle);\n runtime.monitorHandle = null;\n }\n session.cut();\n runtime.audioGraph?.teardown();\n runtime.audioGraph = null;\n}\n\n// One VAD tick against the audio graph's current window. No-op when the graph\n// is not attached (defensive: `stopCapture` clears `monitorHandle` before\n// `audioGraph.teardown()`, so this is the belt-and-braces for any late fire).\nfunction monitorTick(runtime: CaptureRuntime, session: RecorderSession): void {\n if (!runtime.audioGraph?.isAttached()) return;\n const rms = computeRms(runtime.audioGraph.sample());\n const {\n hasSpeech,\n silenceStart: nextSilence,\n cut,\n } = evaluateVad({ hasSpeech: session.hadSpeech(), silenceStart: runtime.silenceStart }, runtime.segmentStart, rms, Date.now(), VAD_CONFIG);\n if (hasSpeech) session.markSpeech();\n runtime.silenceStart = nextSilence;\n if (cut) session.cut();\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n const state = createCaptureState(callbacks.onState);\n const poller = createAvailabilityPoller(transport, state.setAvailable);\n const runtime: CaptureRuntime = { ...INITIAL_RUNTIME };\n const segments = createSegmentQueue({ transport, language, callbacks, setPending: state.setPending, getGeneration: () => runtime.generation });\n\n const session = createRecorderSession({ onSegmentEnd: handleSegmentEnd });\n\n function handleSegmentEnd(blob: Blob, hadSpeech: boolean, gen: number): void {\n // Restart FIRST so audio-loss between segments is minimal; the snapshot\n // in blob/hadSpeech/gen is already independent of session state.\n if (state.isListening()) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === runtime.generation) segments.enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n const stream = runtime.audioGraph?.getStream();\n if (!stream) return;\n runtime.silenceStart = null;\n runtime.segmentStart = Date.now();\n session.startNext(stream, runtime.mimeType, runtime.generation);\n }\n\n async function start(): Promise<boolean> {\n if (runtime.startInFlight || state.isListening()) return true;\n runtime.startInFlight = true;\n const startGen = runtime.generation;\n try {\n const capture = await bootstrapCapture(callbacks, startGen, () => runtime.generation);\n if (!capture) return false;\n runtime.audioGraph = capture.audioGraph;\n runtime.mimeType = capture.mimeType;\n state.setListening(true);\n startRecorder();\n runtime.monitorHandle = window.setInterval(() => monitorTick(runtime, session), MONITOR_INTERVAL_MS);\n return true;\n } finally {\n runtime.startInFlight = false;\n }\n }\n\n function stop(): void {\n stopCapture(runtime, state, session);\n }\n\n function dispose(): void {\n poller.stop();\n stop();\n }\n\n return { refreshAvailability: poller.refresh, start, stop, dispose };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.cjs","names":[],"sources":["../../src/whisper/client-helpers.ts","../../src/whisper/client.ts"],"sourcesContent":["// Pure, framework-neutral helpers for the browser voice-capture controller.\n// Kept separate from `client.ts` so the audio math and VAD state machine can be\n// unit-tested without a DOM / MediaRecorder / Web Audio environment.\n\nexport function 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\n/** The recorder mime can carry a codec suffix (`audio/webm;codecs=opus`); the\n * Blob container type is just the part before the `;`. */\nexport function containerTypeFromMime(mimeType: string): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n}\n\nexport interface VadState {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n}\n\nexport interface VadConfig {\n readonly speechRms: number;\n readonly silenceMs: number;\n readonly maxSegmentMs: number;\n}\n\nexport interface VadDecision {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n readonly cut: boolean;\n}\n\nfunction isSpeech(rms: number, config: VadConfig): boolean {\n return rms > config.speechRms;\n}\n\nfunction nextSilenceStart(state: VadState, rms: number, nowMs: number, config: VadConfig): number | null {\n if (isSpeech(rms, config)) return null;\n if (state.hasSpeech && state.silenceStart === null) return nowMs;\n return state.silenceStart;\n}\n\nfunction silenceExceeded(state: VadState, rms: number, nowMs: number, config: VadConfig): boolean {\n if (isSpeech(rms, config) || !state.hasSpeech || state.silenceStart === null) return false;\n return nowMs - state.silenceStart > config.silenceMs;\n}\n\n/** Advance the pause-detector one tick: fold the current RMS/time into the\n * segment state and decide whether the segment should be force-cut (either a\n * long-enough silence after speech, or the max-segment length reached). */\nexport function evaluateVad(state: VadState, segmentStartMs: number, rms: number, nowMs: number, config: VadConfig): VadDecision {\n const hasSpeech = isSpeech(rms, config) || state.hasSpeech;\n const silenceStart = nextSilenceStart(state, rms, nowMs, config);\n const maxCut = hasSpeech && nowMs - segmentStartMs > config.maxSegmentMs;\n return { hasSpeech, silenceStart, cut: silenceExceeded(state, rms, nowMs, config) || maxCut };\n}\n","// @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\nimport { computeRms, containerTypeFromMime, evaluateVad, type VadConfig } from \"./client-helpers.ts\";\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;\nconst VAD_CONFIG: VadConfig = { speechRms: SPEECH_RMS, silenceMs: SILENCE_MS, maxSegmentMs: MAX_SEGMENT_MS };\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 // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FileReader.result is `string | ArrayBuffer | null`; readAsDataURL always yields the string branch, and the rule cannot narrow it.\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 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) | undefined;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: ((message: string) => void) | undefined;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: ((state: VoiceCaptureState) => void) | undefined;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport interface CaptureStateController {\n setAvailable: (value: boolean) => void;\n setListening: (value: boolean) => void;\n setPending: (delta: number) => void;\n isListening: () => boolean;\n}\n\n// Owns the three observable flags. Each setter emits only on an actual change so\n// the host's reactivity never churns on a no-op write. `pending` is a private\n// counter; `transcribing` is true exactly while at least one send is in flight.\nexport function createCaptureState(onState?: (state: VoiceCaptureState) => void): CaptureStateController {\n let available = false;\n let listening = false;\n let transcribing = false;\n let pending = 0;\n\n function emit(): void {\n 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 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 return { setAvailable, setListening, setPending, isListening: () => listening };\n}\n\nexport interface AvailabilityPoller {\n refresh: () => Promise<void>;\n stop: () => void;\n}\n\n// Owns the polling timer. `refresh` mirrors transport readiness into\n// `setAvailable`; while a model is downloading it self-schedules so `available`\n// flips as soon as the download finishes. A `getStatus` throw fails closed\n// (unavailable) and tears the timer down.\nexport function createAvailabilityPoller(transport: VoiceCaptureTransport, setAvailable: (value: boolean) => void): AvailabilityPoller {\n let availabilityPollHandle: number | null = null;\n\n function stop(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refresh(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stop();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refresh();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stop();\n }\n }\n\n return { refresh, stop };\n}\n\nexport interface SegmentQueueOptions {\n transport: VoiceCaptureTransport;\n language: () => string;\n callbacks: VoiceCaptureCallbacks;\n setPending: (delta: number) => void;\n // Read-only epoch seam: the queue never mutates the generation, it only reads\n // the parent's current value to drop segments captured under an older session.\n getGeneration: () => number;\n}\n\nexport interface SegmentQueue {\n enqueue: (blob: Blob, gen: number) => void;\n}\n\n// Owns the serialized send chain. Each blob is transcribed in capture order; the\n// generation is checked at entry and again after transcription so a segment\n// belonging to a session the user already left is dropped silently.\nexport function createSegmentQueue(options: SegmentQueueOptions): SegmentQueue {\n const { transport, language, callbacks, setPending, getGeneration } = options;\n let queue: Promise<void> = Promise.resolve();\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== getGeneration()) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== getGeneration()) 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 === getGeneration()) 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 return { enqueue };\n}\n\nexport interface AudioGraph {\n /** True between `attach()` and the next `teardown()`. */\n isAttached: () => boolean;\n /** Read the current PCM window into an internal buffer and return it.\n * Callers must not mutate the returned array. */\n sample: () => Float32Array;\n /** Live mic stream — the outer needs it to hand to MediaRecorder. */\n getStream: () => MediaStream | null;\n /** Attach a fresh mic stream: create AudioContext + AnalyserNode + VAD buffer,\n * connect the source. Called only by `bootstrapCapture` after the caller has\n * passed the stale-generation / permission checks. */\n attach: (stream: MediaStream) => void;\n /** Close the AudioContext, stop the mic tracks, null everything. Safe to\n * call before `attach()` or twice in a row. */\n teardown: () => void;\n}\n\n// Owns the browser-side audio pipeline: mic stream + AudioContext + AnalyserNode\n// + the reusable Float32 buffer VAD reads samples into. Separated from\n// `createVoiceCapture` so its four browser-API fields don't inflate the parent\n// factory's line count and so the setup/teardown ordering is in one place.\nfunction createAudioGraph(): AudioGraph {\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\n function attach(nextStream: MediaStream): void {\n stream = nextStream;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(nextStream).connect(analyser);\n }\n\n function sample(): Float32Array {\n analyser?.getFloatTimeDomainData(vadBuffer);\n return vadBuffer;\n }\n\n function teardown(): void {\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 return { isAttached: () => analyser !== null, sample, getStream: () => stream, attach, teardown };\n}\n\ninterface BootstrappedCapture {\n audioGraph: AudioGraph;\n mimeType: string;\n}\n\n// Acquire the mic + probe MediaRecorder support + wire an AudioGraph, returning\n// null (with `callbacks.onError` fired) on any recoverable failure. Deliberately\n// does NOT touch `state` or start the recorder — the caller commits the session\n// only after this resolves successfully, so a stale `generation` detected mid-\n// flight can drop the tracks and bail without partially publishing state.\nasync function bootstrapCapture(callbacks: VoiceCaptureCallbacks, generationAtStart: number, generationNow: () => number): Promise<BootstrappedCapture | null> {\n const mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return null;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return null;\n }\n if (generationAtStart !== generationNow()) {\n acquired.getTracks().forEach((track) => track.stop());\n return null;\n }\n const audioGraph = createAudioGraph();\n try {\n audioGraph.attach(acquired);\n } catch (err) {\n // teardown() is null-safe and closes any half-created AudioContext AND\n // stops the mic tracks — the pre-refactor code only stopped tracks and\n // leaked the context (codex/CodeRabbit findings on this PR).\n audioGraph.teardown();\n throw err;\n }\n return { audioGraph, mimeType };\n}\n\nexport interface RecorderSession {\n isRecording: () => boolean;\n hadSpeech: () => boolean;\n markSpeech: () => void;\n /** Start a new segment on `stream`, tagged with `gen`. Resets per-segment\n * state (chunks + hadSpeech) synchronously so a subsequent VAD tick sees\n * a fresh window. */\n startNext: (stream: MediaStream, mimeType: string, gen: number) => void;\n /** Force-close the current segment (silence exceeded or MAX_SEGMENT_MS). */\n cut: () => void;\n}\n\ninterface RecorderSessionDeps {\n /** Fires each time a segment finishes. The parent decides whether to\n * restart (via `session.startNext`) and whether to enqueue the blob. */\n onSegmentEnd: (blob: Blob, hadSpeech: boolean, gen: number) => void;\n}\n\n// Owns MediaRecorder + its per-segment scratchpad (chunks, hadSpeech, gen,\n// mimeType). The parent keeps VAD state and the global generation, and calls\n// `startNext` on both initial-start and post-onstop restart paths.\nfunction createRecorderSession(deps: RecorderSessionDeps): RecorderSession {\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let segmentHasSpeech = false;\n let sessionGen = 0;\n let sessionMimeType = \"\";\n\n function onStop(): void {\n // Snapshot BEFORE the parent's onSegmentEnd callback runs; `startNext`\n // (if the parent chooses to restart) will clear chunks synchronously.\n const hadSpeech = segmentHasSpeech;\n const gen = sessionGen;\n const blob = new Blob(chunks, { type: containerTypeFromMime(sessionMimeType) });\n deps.onSegmentEnd(blob, hadSpeech, gen);\n }\n\n function startNext(stream: MediaStream, mimeType: string, gen: number): void {\n chunks = [];\n segmentHasSpeech = false;\n sessionGen = gen;\n sessionMimeType = mimeType;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onStop;\n recorder.start();\n }\n\n return {\n isRecording: () => recorder?.state === \"recording\",\n hadSpeech: () => segmentHasSpeech,\n markSpeech: () => {\n segmentHasSpeech = true;\n },\n startNext,\n cut: () => {\n if (recorder?.state === \"recording\") recorder.stop();\n },\n };\n}\n\n// Zero-values used to seed a fresh `CaptureRuntime` at the top of every\n// `createVoiceCapture` call. Kept module-scope so the seed doesn't inflate the\n// factory's line count.\nconst INITIAL_RUNTIME: Readonly<CaptureRuntime> = {\n audioGraph: null,\n monitorHandle: null,\n mimeType: \"\",\n silenceStart: null,\n segmentStart: 0,\n generation: 0,\n startInFlight: false,\n};\n\n// Mutable capture-time state, kept as one object so module-scope helpers\n// (`stopCapture`, `monitorTick`) can be typed against one shape rather than\n// threading each field through their signatures.\ninterface CaptureRuntime {\n audioGraph: AudioGraph | null;\n monitorHandle: number | null;\n mimeType: string;\n silenceStart: number | null;\n segmentStart: number;\n /** Bumped on stop(); segments captured under an older value are dropped so a\n * late transcript never leaks across sessions. */\n generation: number;\n /** Single-flight guard on start() — true between entry and completion so a\n * second start can't race the first before `listening` flips. */\n startInFlight: boolean;\n}\n\n// Tear down the browser-side resources. `state.setListening(false)` MUST happen\n// BEFORE `session.cut()` so the parent's onSegmentEnd callback sees\n// isListening()===false and does NOT restart. The monitor interval MUST clear\n// BEFORE `audioGraph.teardown()` so a scheduled tick can't run against a\n// half-closed AudioContext.\nfunction stopCapture(runtime: CaptureRuntime, state: CaptureStateController, session: RecorderSession): void {\n runtime.generation += 1;\n state.setListening(false);\n if (runtime.monitorHandle !== null) {\n window.clearInterval(runtime.monitorHandle);\n runtime.monitorHandle = null;\n }\n session.cut();\n runtime.audioGraph?.teardown();\n runtime.audioGraph = null;\n}\n\n// One VAD tick against the audio graph's current window. No-op when the graph\n// is not attached (defensive: `stopCapture` clears `monitorHandle` before\n// `audioGraph.teardown()`, so this is the belt-and-braces for any late fire).\nfunction monitorTick(runtime: CaptureRuntime, session: RecorderSession): void {\n if (!runtime.audioGraph?.isAttached()) return;\n const rms = computeRms(runtime.audioGraph.sample());\n const {\n hasSpeech,\n silenceStart: nextSilence,\n cut,\n } = evaluateVad({ hasSpeech: session.hadSpeech(), silenceStart: runtime.silenceStart }, runtime.segmentStart, rms, Date.now(), VAD_CONFIG);\n if (hasSpeech) session.markSpeech();\n runtime.silenceStart = nextSilence;\n if (cut) session.cut();\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n const state = createCaptureState(callbacks.onState);\n const poller = createAvailabilityPoller(transport, state.setAvailable);\n const runtime: CaptureRuntime = { ...INITIAL_RUNTIME };\n const segments = createSegmentQueue({ transport, language, callbacks, setPending: state.setPending, getGeneration: () => runtime.generation });\n\n const session = createRecorderSession({ onSegmentEnd: handleSegmentEnd });\n\n function handleSegmentEnd(blob: Blob, hadSpeech: boolean, gen: number): void {\n // Restart FIRST so audio-loss between segments is minimal; the snapshot\n // in blob/hadSpeech/gen is already independent of session state.\n if (state.isListening()) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === runtime.generation) segments.enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n const stream = runtime.audioGraph?.getStream();\n if (!stream) return;\n runtime.silenceStart = null;\n runtime.segmentStart = Date.now();\n session.startNext(stream, runtime.mimeType, runtime.generation);\n }\n\n async function start(): Promise<boolean> {\n if (runtime.startInFlight || state.isListening()) return true;\n runtime.startInFlight = true;\n const startGen = runtime.generation;\n try {\n const capture = await bootstrapCapture(callbacks, startGen, () => runtime.generation);\n if (!capture) return false;\n runtime.audioGraph = capture.audioGraph;\n runtime.mimeType = capture.mimeType;\n state.setListening(true);\n startRecorder();\n runtime.monitorHandle = window.setInterval(() => monitorTick(runtime, session), MONITOR_INTERVAL_MS);\n return true;\n } finally {\n runtime.startInFlight = false;\n }\n }\n\n function stop(): void {\n stopCapture(runtime, state, session);\n }\n\n function dispose(): void {\n poller.stop();\n stop();\n }\n\n return { refreshAvailability: poller.refresh, start, stop, dispose };\n}\n"],"mappings":"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"}
|
package/dist/whisper/client.d.ts
CHANGED
|
@@ -20,11 +20,11 @@ export interface VoiceCaptureCallbacks {
|
|
|
20
20
|
/** A recognized (non-empty) segment transcript. */
|
|
21
21
|
onTranscript: (text: string) => void;
|
|
22
22
|
/** A segment produced no speech. */
|
|
23
|
-
onEmpty?: () => void;
|
|
23
|
+
onEmpty?: (() => void) | undefined;
|
|
24
24
|
/** A recoverable error message (transport failure, permission denied, etc.). */
|
|
25
|
-
onError?: (message: string) => void;
|
|
25
|
+
onError?: ((message: string) => void) | undefined;
|
|
26
26
|
/** Pushed whenever available/listening/transcribing changes. */
|
|
27
|
-
onState?: (state: VoiceCaptureState) => void;
|
|
27
|
+
onState?: ((state: VoiceCaptureState) => void) | undefined;
|
|
28
28
|
}
|
|
29
29
|
export interface VoiceCapture {
|
|
30
30
|
refreshAvailability: () => Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client-helpers.ts","../../src/whisper/client.ts"],"sourcesContent":["// Pure, framework-neutral helpers for the browser voice-capture controller.\n// Kept separate from `client.ts` so the audio math and VAD state machine can be\n// unit-tested without a DOM / MediaRecorder / Web Audio environment.\n\nexport function 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\n/** The recorder mime can carry a codec suffix (`audio/webm;codecs=opus`); the\n * Blob container type is just the part before the `;`. */\nexport function containerTypeFromMime(mimeType: string): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n}\n\nexport interface VadState {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n}\n\nexport interface VadConfig {\n readonly speechRms: number;\n readonly silenceMs: number;\n readonly maxSegmentMs: number;\n}\n\nexport interface VadDecision {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n readonly cut: boolean;\n}\n\nfunction isSpeech(rms: number, config: VadConfig): boolean {\n return rms > config.speechRms;\n}\n\nfunction nextSilenceStart(state: VadState, rms: number, nowMs: number, config: VadConfig): number | null {\n if (isSpeech(rms, config)) return null;\n if (state.hasSpeech && state.silenceStart === null) return nowMs;\n return state.silenceStart;\n}\n\nfunction silenceExceeded(state: VadState, rms: number, nowMs: number, config: VadConfig): boolean {\n if (isSpeech(rms, config) || !state.hasSpeech || state.silenceStart === null) return false;\n return nowMs - state.silenceStart > config.silenceMs;\n}\n\n/** Advance the pause-detector one tick: fold the current RMS/time into the\n * segment state and decide whether the segment should be force-cut (either a\n * long-enough silence after speech, or the max-segment length reached). */\nexport function evaluateVad(state: VadState, segmentStartMs: number, rms: number, nowMs: number, config: VadConfig): VadDecision {\n const hasSpeech = isSpeech(rms, config) || state.hasSpeech;\n const silenceStart = nextSilenceStart(state, rms, nowMs, config);\n const maxCut = hasSpeech && nowMs - segmentStartMs > config.maxSegmentMs;\n return { hasSpeech, silenceStart, cut: silenceExceeded(state, rms, nowMs, config) || maxCut };\n}\n","// @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\nimport { computeRms, containerTypeFromMime, evaluateVad, type VadConfig } from \"./client-helpers.ts\";\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;\nconst VAD_CONFIG: VadConfig = { speechRms: SPEECH_RMS, silenceMs: SILENCE_MS, maxSegmentMs: MAX_SEGMENT_MS };\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 // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FileReader.result is `string | ArrayBuffer | null`; readAsDataURL always yields the string branch, and the rule cannot narrow it.\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 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 interface CaptureStateController {\n setAvailable: (value: boolean) => void;\n setListening: (value: boolean) => void;\n setPending: (delta: number) => void;\n isListening: () => boolean;\n}\n\n// Owns the three observable flags. Each setter emits only on an actual change so\n// the host's reactivity never churns on a no-op write. `pending` is a private\n// counter; `transcribing` is true exactly while at least one send is in flight.\nexport function createCaptureState(onState?: (state: VoiceCaptureState) => void): CaptureStateController {\n let available = false;\n let listening = false;\n let transcribing = false;\n let pending = 0;\n\n function emit(): void {\n 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 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 return { setAvailable, setListening, setPending, isListening: () => listening };\n}\n\nexport interface AvailabilityPoller {\n refresh: () => Promise<void>;\n stop: () => void;\n}\n\n// Owns the polling timer. `refresh` mirrors transport readiness into\n// `setAvailable`; while a model is downloading it self-schedules so `available`\n// flips as soon as the download finishes. A `getStatus` throw fails closed\n// (unavailable) and tears the timer down.\nexport function createAvailabilityPoller(transport: VoiceCaptureTransport, setAvailable: (value: boolean) => void): AvailabilityPoller {\n let availabilityPollHandle: number | null = null;\n\n function stop(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refresh(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stop();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refresh();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stop();\n }\n }\n\n return { refresh, stop };\n}\n\nexport interface SegmentQueueOptions {\n transport: VoiceCaptureTransport;\n language: () => string;\n callbacks: VoiceCaptureCallbacks;\n setPending: (delta: number) => void;\n // Read-only epoch seam: the queue never mutates the generation, it only reads\n // the parent's current value to drop segments captured under an older session.\n getGeneration: () => number;\n}\n\nexport interface SegmentQueue {\n enqueue: (blob: Blob, gen: number) => void;\n}\n\n// Owns the serialized send chain. Each blob is transcribed in capture order; the\n// generation is checked at entry and again after transcription so a segment\n// belonging to a session the user already left is dropped silently.\nexport function createSegmentQueue(options: SegmentQueueOptions): SegmentQueue {\n const { transport, language, callbacks, setPending, getGeneration } = options;\n let queue: Promise<void> = Promise.resolve();\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== getGeneration()) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== getGeneration()) 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 === getGeneration()) 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 return { enqueue };\n}\n\nexport interface AudioGraph {\n /** True between `attach()` and the next `teardown()`. */\n isAttached: () => boolean;\n /** Read the current PCM window into an internal buffer and return it.\n * Callers must not mutate the returned array. */\n sample: () => Float32Array;\n /** Live mic stream — the outer needs it to hand to MediaRecorder. */\n getStream: () => MediaStream | null;\n /** Attach a fresh mic stream: create AudioContext + AnalyserNode + VAD buffer,\n * connect the source. Called only by `bootstrapCapture` after the caller has\n * passed the stale-generation / permission checks. */\n attach: (stream: MediaStream) => void;\n /** Close the AudioContext, stop the mic tracks, null everything. Safe to\n * call before `attach()` or twice in a row. */\n teardown: () => void;\n}\n\n// Owns the browser-side audio pipeline: mic stream + AudioContext + AnalyserNode\n// + the reusable Float32 buffer VAD reads samples into. Separated from\n// `createVoiceCapture` so its four browser-API fields don't inflate the parent\n// factory's line count and so the setup/teardown ordering is in one place.\nfunction createAudioGraph(): AudioGraph {\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\n function attach(nextStream: MediaStream): void {\n stream = nextStream;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(nextStream).connect(analyser);\n }\n\n function sample(): Float32Array {\n analyser?.getFloatTimeDomainData(vadBuffer);\n return vadBuffer;\n }\n\n function teardown(): void {\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 return { isAttached: () => analyser !== null, sample, getStream: () => stream, attach, teardown };\n}\n\ninterface BootstrappedCapture {\n audioGraph: AudioGraph;\n mimeType: string;\n}\n\n// Acquire the mic + probe MediaRecorder support + wire an AudioGraph, returning\n// null (with `callbacks.onError` fired) on any recoverable failure. Deliberately\n// does NOT touch `state` or start the recorder — the caller commits the session\n// only after this resolves successfully, so a stale `generation` detected mid-\n// flight can drop the tracks and bail without partially publishing state.\nasync function bootstrapCapture(callbacks: VoiceCaptureCallbacks, generationAtStart: number, generationNow: () => number): Promise<BootstrappedCapture | null> {\n const mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return null;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return null;\n }\n if (generationAtStart !== generationNow()) {\n acquired.getTracks().forEach((track) => track.stop());\n return null;\n }\n const audioGraph = createAudioGraph();\n try {\n audioGraph.attach(acquired);\n } catch (err) {\n // teardown() is null-safe and closes any half-created AudioContext AND\n // stops the mic tracks — the pre-refactor code only stopped tracks and\n // leaked the context (codex/CodeRabbit findings on this PR).\n audioGraph.teardown();\n throw err;\n }\n return { audioGraph, mimeType };\n}\n\nexport interface RecorderSession {\n isRecording: () => boolean;\n hadSpeech: () => boolean;\n markSpeech: () => void;\n /** Start a new segment on `stream`, tagged with `gen`. Resets per-segment\n * state (chunks + hadSpeech) synchronously so a subsequent VAD tick sees\n * a fresh window. */\n startNext: (stream: MediaStream, mimeType: string, gen: number) => void;\n /** Force-close the current segment (silence exceeded or MAX_SEGMENT_MS). */\n cut: () => void;\n}\n\ninterface RecorderSessionDeps {\n /** Fires each time a segment finishes. The parent decides whether to\n * restart (via `session.startNext`) and whether to enqueue the blob. */\n onSegmentEnd: (blob: Blob, hadSpeech: boolean, gen: number) => void;\n}\n\n// Owns MediaRecorder + its per-segment scratchpad (chunks, hadSpeech, gen,\n// mimeType). The parent keeps VAD state and the global generation, and calls\n// `startNext` on both initial-start and post-onstop restart paths.\nfunction createRecorderSession(deps: RecorderSessionDeps): RecorderSession {\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let segmentHasSpeech = false;\n let sessionGen = 0;\n let sessionMimeType = \"\";\n\n function onStop(): void {\n // Snapshot BEFORE the parent's onSegmentEnd callback runs; `startNext`\n // (if the parent chooses to restart) will clear chunks synchronously.\n const hadSpeech = segmentHasSpeech;\n const gen = sessionGen;\n const blob = new Blob(chunks, { type: containerTypeFromMime(sessionMimeType) });\n deps.onSegmentEnd(blob, hadSpeech, gen);\n }\n\n function startNext(stream: MediaStream, mimeType: string, gen: number): void {\n chunks = [];\n segmentHasSpeech = false;\n sessionGen = gen;\n sessionMimeType = mimeType;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onStop;\n recorder.start();\n }\n\n return {\n isRecording: () => recorder?.state === \"recording\",\n hadSpeech: () => segmentHasSpeech,\n markSpeech: () => {\n segmentHasSpeech = true;\n },\n startNext,\n cut: () => {\n if (recorder?.state === \"recording\") recorder.stop();\n },\n };\n}\n\n// Zero-values used to seed a fresh `CaptureRuntime` at the top of every\n// `createVoiceCapture` call. Kept module-scope so the seed doesn't inflate the\n// factory's line count.\nconst INITIAL_RUNTIME: Readonly<CaptureRuntime> = {\n audioGraph: null,\n monitorHandle: null,\n mimeType: \"\",\n silenceStart: null,\n segmentStart: 0,\n generation: 0,\n startInFlight: false,\n};\n\n// Mutable capture-time state, kept as one object so module-scope helpers\n// (`stopCapture`, `monitorTick`) can be typed against one shape rather than\n// threading each field through their signatures.\ninterface CaptureRuntime {\n audioGraph: AudioGraph | null;\n monitorHandle: number | null;\n mimeType: string;\n silenceStart: number | null;\n segmentStart: number;\n /** Bumped on stop(); segments captured under an older value are dropped so a\n * late transcript never leaks across sessions. */\n generation: number;\n /** Single-flight guard on start() — true between entry and completion so a\n * second start can't race the first before `listening` flips. */\n startInFlight: boolean;\n}\n\n// Tear down the browser-side resources. `state.setListening(false)` MUST happen\n// BEFORE `session.cut()` so the parent's onSegmentEnd callback sees\n// isListening()===false and does NOT restart. The monitor interval MUST clear\n// BEFORE `audioGraph.teardown()` so a scheduled tick can't run against a\n// half-closed AudioContext.\nfunction stopCapture(runtime: CaptureRuntime, state: CaptureStateController, session: RecorderSession): void {\n runtime.generation += 1;\n state.setListening(false);\n if (runtime.monitorHandle !== null) {\n window.clearInterval(runtime.monitorHandle);\n runtime.monitorHandle = null;\n }\n session.cut();\n runtime.audioGraph?.teardown();\n runtime.audioGraph = null;\n}\n\n// One VAD tick against the audio graph's current window. No-op when the graph\n// is not attached (defensive: `stopCapture` clears `monitorHandle` before\n// `audioGraph.teardown()`, so this is the belt-and-braces for any late fire).\nfunction monitorTick(runtime: CaptureRuntime, session: RecorderSession): void {\n if (!runtime.audioGraph?.isAttached()) return;\n const rms = computeRms(runtime.audioGraph.sample());\n const {\n hasSpeech,\n silenceStart: nextSilence,\n cut,\n } = evaluateVad({ hasSpeech: session.hadSpeech(), silenceStart: runtime.silenceStart }, runtime.segmentStart, rms, Date.now(), VAD_CONFIG);\n if (hasSpeech) session.markSpeech();\n runtime.silenceStart = nextSilence;\n if (cut) session.cut();\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n const state = createCaptureState(callbacks.onState);\n const poller = createAvailabilityPoller(transport, state.setAvailable);\n const runtime: CaptureRuntime = { ...INITIAL_RUNTIME };\n const segments = createSegmentQueue({ transport, language, callbacks, setPending: state.setPending, getGeneration: () => runtime.generation });\n\n const session = createRecorderSession({ onSegmentEnd: handleSegmentEnd });\n\n function handleSegmentEnd(blob: Blob, hadSpeech: boolean, gen: number): void {\n // Restart FIRST so audio-loss between segments is minimal; the snapshot\n // in blob/hadSpeech/gen is already independent of session state.\n if (state.isListening()) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === runtime.generation) segments.enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n const stream = runtime.audioGraph?.getStream();\n if (!stream) return;\n runtime.silenceStart = null;\n runtime.segmentStart = Date.now();\n session.startNext(stream, runtime.mimeType, runtime.generation);\n }\n\n async function start(): Promise<boolean> {\n if (runtime.startInFlight || state.isListening()) return true;\n runtime.startInFlight = true;\n const startGen = runtime.generation;\n try {\n const capture = await bootstrapCapture(callbacks, startGen, () => runtime.generation);\n if (!capture) return false;\n runtime.audioGraph = capture.audioGraph;\n runtime.mimeType = capture.mimeType;\n state.setListening(true);\n startRecorder();\n runtime.monitorHandle = window.setInterval(() => monitorTick(runtime, session), MONITOR_INTERVAL_MS);\n return true;\n } finally {\n runtime.startInFlight = false;\n }\n }\n\n function stop(): void {\n stopCapture(runtime, state, session);\n }\n\n function dispose(): void {\n poller.stop();\n stop();\n }\n\n return { refreshAvailability: poller.refresh, start, stop, dispose };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client-helpers.ts","../../src/whisper/client.ts"],"sourcesContent":["// Pure, framework-neutral helpers for the browser voice-capture controller.\n// Kept separate from `client.ts` so the audio math and VAD state machine can be\n// unit-tested without a DOM / MediaRecorder / Web Audio environment.\n\nexport function 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\n/** The recorder mime can carry a codec suffix (`audio/webm;codecs=opus`); the\n * Blob container type is just the part before the `;`. */\nexport function containerTypeFromMime(mimeType: string): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n}\n\nexport interface VadState {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n}\n\nexport interface VadConfig {\n readonly speechRms: number;\n readonly silenceMs: number;\n readonly maxSegmentMs: number;\n}\n\nexport interface VadDecision {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n readonly cut: boolean;\n}\n\nfunction isSpeech(rms: number, config: VadConfig): boolean {\n return rms > config.speechRms;\n}\n\nfunction nextSilenceStart(state: VadState, rms: number, nowMs: number, config: VadConfig): number | null {\n if (isSpeech(rms, config)) return null;\n if (state.hasSpeech && state.silenceStart === null) return nowMs;\n return state.silenceStart;\n}\n\nfunction silenceExceeded(state: VadState, rms: number, nowMs: number, config: VadConfig): boolean {\n if (isSpeech(rms, config) || !state.hasSpeech || state.silenceStart === null) return false;\n return nowMs - state.silenceStart > config.silenceMs;\n}\n\n/** Advance the pause-detector one tick: fold the current RMS/time into the\n * segment state and decide whether the segment should be force-cut (either a\n * long-enough silence after speech, or the max-segment length reached). */\nexport function evaluateVad(state: VadState, segmentStartMs: number, rms: number, nowMs: number, config: VadConfig): VadDecision {\n const hasSpeech = isSpeech(rms, config) || state.hasSpeech;\n const silenceStart = nextSilenceStart(state, rms, nowMs, config);\n const maxCut = hasSpeech && nowMs - segmentStartMs > config.maxSegmentMs;\n return { hasSpeech, silenceStart, cut: silenceExceeded(state, rms, nowMs, config) || maxCut };\n}\n","// @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\nimport { computeRms, containerTypeFromMime, evaluateVad, type VadConfig } from \"./client-helpers.ts\";\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;\nconst VAD_CONFIG: VadConfig = { speechRms: SPEECH_RMS, silenceMs: SILENCE_MS, maxSegmentMs: MAX_SEGMENT_MS };\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 // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FileReader.result is `string | ArrayBuffer | null`; readAsDataURL always yields the string branch, and the rule cannot narrow it.\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 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) | undefined;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: ((message: string) => void) | undefined;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: ((state: VoiceCaptureState) => void) | undefined;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport interface CaptureStateController {\n setAvailable: (value: boolean) => void;\n setListening: (value: boolean) => void;\n setPending: (delta: number) => void;\n isListening: () => boolean;\n}\n\n// Owns the three observable flags. Each setter emits only on an actual change so\n// the host's reactivity never churns on a no-op write. `pending` is a private\n// counter; `transcribing` is true exactly while at least one send is in flight.\nexport function createCaptureState(onState?: (state: VoiceCaptureState) => void): CaptureStateController {\n let available = false;\n let listening = false;\n let transcribing = false;\n let pending = 0;\n\n function emit(): void {\n 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 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 return { setAvailable, setListening, setPending, isListening: () => listening };\n}\n\nexport interface AvailabilityPoller {\n refresh: () => Promise<void>;\n stop: () => void;\n}\n\n// Owns the polling timer. `refresh` mirrors transport readiness into\n// `setAvailable`; while a model is downloading it self-schedules so `available`\n// flips as soon as the download finishes. A `getStatus` throw fails closed\n// (unavailable) and tears the timer down.\nexport function createAvailabilityPoller(transport: VoiceCaptureTransport, setAvailable: (value: boolean) => void): AvailabilityPoller {\n let availabilityPollHandle: number | null = null;\n\n function stop(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refresh(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stop();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refresh();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stop();\n }\n }\n\n return { refresh, stop };\n}\n\nexport interface SegmentQueueOptions {\n transport: VoiceCaptureTransport;\n language: () => string;\n callbacks: VoiceCaptureCallbacks;\n setPending: (delta: number) => void;\n // Read-only epoch seam: the queue never mutates the generation, it only reads\n // the parent's current value to drop segments captured under an older session.\n getGeneration: () => number;\n}\n\nexport interface SegmentQueue {\n enqueue: (blob: Blob, gen: number) => void;\n}\n\n// Owns the serialized send chain. Each blob is transcribed in capture order; the\n// generation is checked at entry and again after transcription so a segment\n// belonging to a session the user already left is dropped silently.\nexport function createSegmentQueue(options: SegmentQueueOptions): SegmentQueue {\n const { transport, language, callbacks, setPending, getGeneration } = options;\n let queue: Promise<void> = Promise.resolve();\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== getGeneration()) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== getGeneration()) 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 === getGeneration()) 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 return { enqueue };\n}\n\nexport interface AudioGraph {\n /** True between `attach()` and the next `teardown()`. */\n isAttached: () => boolean;\n /** Read the current PCM window into an internal buffer and return it.\n * Callers must not mutate the returned array. */\n sample: () => Float32Array;\n /** Live mic stream — the outer needs it to hand to MediaRecorder. */\n getStream: () => MediaStream | null;\n /** Attach a fresh mic stream: create AudioContext + AnalyserNode + VAD buffer,\n * connect the source. Called only by `bootstrapCapture` after the caller has\n * passed the stale-generation / permission checks. */\n attach: (stream: MediaStream) => void;\n /** Close the AudioContext, stop the mic tracks, null everything. Safe to\n * call before `attach()` or twice in a row. */\n teardown: () => void;\n}\n\n// Owns the browser-side audio pipeline: mic stream + AudioContext + AnalyserNode\n// + the reusable Float32 buffer VAD reads samples into. Separated from\n// `createVoiceCapture` so its four browser-API fields don't inflate the parent\n// factory's line count and so the setup/teardown ordering is in one place.\nfunction createAudioGraph(): AudioGraph {\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\n function attach(nextStream: MediaStream): void {\n stream = nextStream;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(nextStream).connect(analyser);\n }\n\n function sample(): Float32Array {\n analyser?.getFloatTimeDomainData(vadBuffer);\n return vadBuffer;\n }\n\n function teardown(): void {\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 return { isAttached: () => analyser !== null, sample, getStream: () => stream, attach, teardown };\n}\n\ninterface BootstrappedCapture {\n audioGraph: AudioGraph;\n mimeType: string;\n}\n\n// Acquire the mic + probe MediaRecorder support + wire an AudioGraph, returning\n// null (with `callbacks.onError` fired) on any recoverable failure. Deliberately\n// does NOT touch `state` or start the recorder — the caller commits the session\n// only after this resolves successfully, so a stale `generation` detected mid-\n// flight can drop the tracks and bail without partially publishing state.\nasync function bootstrapCapture(callbacks: VoiceCaptureCallbacks, generationAtStart: number, generationNow: () => number): Promise<BootstrappedCapture | null> {\n const mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return null;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return null;\n }\n if (generationAtStart !== generationNow()) {\n acquired.getTracks().forEach((track) => track.stop());\n return null;\n }\n const audioGraph = createAudioGraph();\n try {\n audioGraph.attach(acquired);\n } catch (err) {\n // teardown() is null-safe and closes any half-created AudioContext AND\n // stops the mic tracks — the pre-refactor code only stopped tracks and\n // leaked the context (codex/CodeRabbit findings on this PR).\n audioGraph.teardown();\n throw err;\n }\n return { audioGraph, mimeType };\n}\n\nexport interface RecorderSession {\n isRecording: () => boolean;\n hadSpeech: () => boolean;\n markSpeech: () => void;\n /** Start a new segment on `stream`, tagged with `gen`. Resets per-segment\n * state (chunks + hadSpeech) synchronously so a subsequent VAD tick sees\n * a fresh window. */\n startNext: (stream: MediaStream, mimeType: string, gen: number) => void;\n /** Force-close the current segment (silence exceeded or MAX_SEGMENT_MS). */\n cut: () => void;\n}\n\ninterface RecorderSessionDeps {\n /** Fires each time a segment finishes. The parent decides whether to\n * restart (via `session.startNext`) and whether to enqueue the blob. */\n onSegmentEnd: (blob: Blob, hadSpeech: boolean, gen: number) => void;\n}\n\n// Owns MediaRecorder + its per-segment scratchpad (chunks, hadSpeech, gen,\n// mimeType). The parent keeps VAD state and the global generation, and calls\n// `startNext` on both initial-start and post-onstop restart paths.\nfunction createRecorderSession(deps: RecorderSessionDeps): RecorderSession {\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let segmentHasSpeech = false;\n let sessionGen = 0;\n let sessionMimeType = \"\";\n\n function onStop(): void {\n // Snapshot BEFORE the parent's onSegmentEnd callback runs; `startNext`\n // (if the parent chooses to restart) will clear chunks synchronously.\n const hadSpeech = segmentHasSpeech;\n const gen = sessionGen;\n const blob = new Blob(chunks, { type: containerTypeFromMime(sessionMimeType) });\n deps.onSegmentEnd(blob, hadSpeech, gen);\n }\n\n function startNext(stream: MediaStream, mimeType: string, gen: number): void {\n chunks = [];\n segmentHasSpeech = false;\n sessionGen = gen;\n sessionMimeType = mimeType;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onStop;\n recorder.start();\n }\n\n return {\n isRecording: () => recorder?.state === \"recording\",\n hadSpeech: () => segmentHasSpeech,\n markSpeech: () => {\n segmentHasSpeech = true;\n },\n startNext,\n cut: () => {\n if (recorder?.state === \"recording\") recorder.stop();\n },\n };\n}\n\n// Zero-values used to seed a fresh `CaptureRuntime` at the top of every\n// `createVoiceCapture` call. Kept module-scope so the seed doesn't inflate the\n// factory's line count.\nconst INITIAL_RUNTIME: Readonly<CaptureRuntime> = {\n audioGraph: null,\n monitorHandle: null,\n mimeType: \"\",\n silenceStart: null,\n segmentStart: 0,\n generation: 0,\n startInFlight: false,\n};\n\n// Mutable capture-time state, kept as one object so module-scope helpers\n// (`stopCapture`, `monitorTick`) can be typed against one shape rather than\n// threading each field through their signatures.\ninterface CaptureRuntime {\n audioGraph: AudioGraph | null;\n monitorHandle: number | null;\n mimeType: string;\n silenceStart: number | null;\n segmentStart: number;\n /** Bumped on stop(); segments captured under an older value are dropped so a\n * late transcript never leaks across sessions. */\n generation: number;\n /** Single-flight guard on start() — true between entry and completion so a\n * second start can't race the first before `listening` flips. */\n startInFlight: boolean;\n}\n\n// Tear down the browser-side resources. `state.setListening(false)` MUST happen\n// BEFORE `session.cut()` so the parent's onSegmentEnd callback sees\n// isListening()===false and does NOT restart. The monitor interval MUST clear\n// BEFORE `audioGraph.teardown()` so a scheduled tick can't run against a\n// half-closed AudioContext.\nfunction stopCapture(runtime: CaptureRuntime, state: CaptureStateController, session: RecorderSession): void {\n runtime.generation += 1;\n state.setListening(false);\n if (runtime.monitorHandle !== null) {\n window.clearInterval(runtime.monitorHandle);\n runtime.monitorHandle = null;\n }\n session.cut();\n runtime.audioGraph?.teardown();\n runtime.audioGraph = null;\n}\n\n// One VAD tick against the audio graph's current window. No-op when the graph\n// is not attached (defensive: `stopCapture` clears `monitorHandle` before\n// `audioGraph.teardown()`, so this is the belt-and-braces for any late fire).\nfunction monitorTick(runtime: CaptureRuntime, session: RecorderSession): void {\n if (!runtime.audioGraph?.isAttached()) return;\n const rms = computeRms(runtime.audioGraph.sample());\n const {\n hasSpeech,\n silenceStart: nextSilence,\n cut,\n } = evaluateVad({ hasSpeech: session.hadSpeech(), silenceStart: runtime.silenceStart }, runtime.segmentStart, rms, Date.now(), VAD_CONFIG);\n if (hasSpeech) session.markSpeech();\n runtime.silenceStart = nextSilence;\n if (cut) session.cut();\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n const state = createCaptureState(callbacks.onState);\n const poller = createAvailabilityPoller(transport, state.setAvailable);\n const runtime: CaptureRuntime = { ...INITIAL_RUNTIME };\n const segments = createSegmentQueue({ transport, language, callbacks, setPending: state.setPending, getGeneration: () => runtime.generation });\n\n const session = createRecorderSession({ onSegmentEnd: handleSegmentEnd });\n\n function handleSegmentEnd(blob: Blob, hadSpeech: boolean, gen: number): void {\n // Restart FIRST so audio-loss between segments is minimal; the snapshot\n // in blob/hadSpeech/gen is already independent of session state.\n if (state.isListening()) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === runtime.generation) segments.enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n const stream = runtime.audioGraph?.getStream();\n if (!stream) return;\n runtime.silenceStart = null;\n runtime.segmentStart = Date.now();\n session.startNext(stream, runtime.mimeType, runtime.generation);\n }\n\n async function start(): Promise<boolean> {\n if (runtime.startInFlight || state.isListening()) return true;\n runtime.startInFlight = true;\n const startGen = runtime.generation;\n try {\n const capture = await bootstrapCapture(callbacks, startGen, () => runtime.generation);\n if (!capture) return false;\n runtime.audioGraph = capture.audioGraph;\n runtime.mimeType = capture.mimeType;\n state.setListening(true);\n startRecorder();\n runtime.monitorHandle = window.setInterval(() => monitorTick(runtime, session), MONITOR_INTERVAL_MS);\n return true;\n } finally {\n runtime.startInFlight = false;\n }\n }\n\n function stop(): void {\n stopCapture(runtime, state, session);\n }\n\n function dispose(): void {\n poller.stop();\n stop();\n }\n\n return { refreshAvailability: poller.refresh, start, stop, dispose };\n}\n"],"mappings":";AAIA,SAAgB,WAAW,QAA8B;CACvD,IAAI,MAAM;CACV,KAAK,MAAM,UAAU,QAAQ,OAAO,SAAS;CAC7C,OAAO,KAAK,KAAK,MAAM,OAAO,MAAM;AACtC;;;AAIA,SAAgB,sBAAsB,UAA0B;CAC9D,OAAO,SAAS,MAAM,GAAG,CAAC,CAAC,MAAM;AACnC;AAmBA,SAAS,SAAS,KAAa,QAA4B;CACzD,OAAO,MAAM,OAAO;AACtB;AAEA,SAAS,iBAAiB,OAAiB,KAAa,OAAe,QAAkC;CACvG,IAAI,SAAS,KAAK,MAAM,GAAG,OAAO;CAClC,IAAI,MAAM,aAAa,MAAM,iBAAiB,MAAM,OAAO;CAC3D,OAAO,MAAM;AACf;AAEA,SAAS,gBAAgB,OAAiB,KAAa,OAAe,QAA4B;CAChG,IAAI,SAAS,KAAK,MAAM,KAAK,CAAC,MAAM,aAAa,MAAM,iBAAiB,MAAM,OAAO;CACrF,OAAO,QAAQ,MAAM,eAAe,OAAO;AAC7C;;;;AAKA,SAAgB,YAAY,OAAiB,gBAAwB,KAAa,OAAe,QAAgC;CAC/H,MAAM,YAAY,SAAS,KAAK,MAAM,KAAK,MAAM;CACjD,MAAM,eAAe,iBAAiB,OAAO,KAAK,OAAO,MAAM;CAC/D,MAAM,SAAS,aAAa,QAAQ,iBAAiB,OAAO;CAC5D,OAAO;EAAE;EAAW;EAAc,KAAK,gBAAgB,OAAO,KAAK,OAAO,MAAM,KAAK;CAAO;AAC9F;;;AC9CA,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;AAC7B,IAAM,aAAwB;CAAE,WAAW;CAAY,WAAW;CAAY,cAAc;AAAe;AAE3G,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;EAE9B,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,UAAU,KAAsB;CACvC,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AA4CA,SAAgB,mBAAmB,SAAsE;CACvG,IAAI,YAAY;CAChB,IAAI,YAAY;CAChB,IAAI,eAAe;CACnB,IAAI,UAAU;CAEd,SAAS,OAAa;EACpB,UAAU;GAAE;GAAW;GAAW;EAAa,CAAC;CAClD;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;CACA,SAAS,WAAW,OAAqB;EACvC,WAAW;EACX,MAAM,OAAO,UAAU;EACvB,IAAI,iBAAiB,MAAM;GACzB,eAAe;GACf,KAAK;EACP;CACF;CAEA,OAAO;EAAE;EAAc;EAAc;EAAY,mBAAmB;CAAU;AAChF;AAWA,SAAgB,yBAAyB,WAAkC,cAA4D;CACrI,IAAI,yBAAwC;CAE5C,SAAS,OAAa;EACpB,IAAI,2BAA2B,MAAM;GACnC,OAAO,cAAc,sBAAsB;GAC3C,yBAAyB;EAC3B;CACF;CAEA,eAAe,UAAyB;EACtC,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,UAAU,UAAU;EACrC,QAAQ;GACN,aAAa,KAAK;GAClB,KAAK;GACL;EACF;EACA,aAAa,OAAO,KAAK;EACzB,IAAI,OAAO;OACL,2BAA2B,MAC7B,yBAAyB,OAAO,kBAAkB;IAChD,QAAa;GACf,GAAG,oBAAoB;EAAA,OAGzB,KAAK;CAET;CAEA,OAAO;EAAE;EAAS;CAAK;AACzB;AAmBA,SAAgB,mBAAmB,SAA4C;CAC7E,MAAM,EAAE,WAAW,UAAU,WAAW,YAAY,kBAAkB;CACtE,IAAI,QAAuB,QAAQ,QAAQ;CAE3C,eAAe,YAAY,MAAY,KAA4B;EACjE,IAAI,QAAQ,cAAc,GAAG;EAC7B,IAAI;GACF,MAAM,UAAU,MAAM,cAAc,IAAI;GACxC,MAAM,SAAS,MAAM,UAAU,WAAW,SAAS,SAAS,CAAC;GAC7D,IAAI,QAAQ,cAAc,GAAG;GAC7B,MAAM,OAAO,OAAO,KAAK,KAAK;GAC9B,IAAI,KAAK,WAAW,GAAG,UAAU,UAAU;QACtC,UAAU,aAAa,IAAI;EAClC,SAAS,KAAK;GAGZ,IAAI,QAAQ,cAAc,GAAG,UAAU,UAAU,UAAU,GAAG,CAAC;EACjE;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,OAAO,EAAE,QAAQ;AACnB;AAuBA,SAAS,mBAA+B;CACtC,IAAI,SAA6B;CACjC,IAAI,WAAgC;CACpC,IAAI,WAAgC;CACpC,IAAI,4BAAY,IAAI,aAAa,CAAC;CAElC,SAAS,OAAO,YAA+B;EAC7C,SAAS;EACT,WAAW,IAAI,aAAa;EAC5B,WAAW,SAAS,eAAe;EACnC,SAAS,UAAU;EACnB,YAAY,IAAI,aAAa,SAAS,OAAO;EAC7C,SAAS,wBAAwB,UAAU,CAAC,CAAC,QAAQ,QAAQ;CAC/D;CAEA,SAAS,SAAuB;EAC9B,UAAU,uBAAuB,SAAS;EAC1C,OAAO;CACT;CAEA,SAAS,WAAiB;EACxB,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,OAAO;EAAE,kBAAkB,aAAa;EAAM;EAAQ,iBAAiB;EAAQ;EAAQ;CAAS;AAClG;AAYA,eAAe,iBAAiB,WAAkC,mBAA2B,eAAkE;CAC7J,MAAM,WAAW,iBAAiB,KAAK;CACvC,IAAI,CAAC,YAAY,CAAC,UAAU,cAAc,cAAc;EACtD,UAAU,UAAU,aAAa;EACjC,OAAO;CACT;CACA,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,UAAU,aAAa,aAAa,EAAE,OAAO,KAAK,CAAC;CACtE,QAAQ;EACN,UAAU,UAAU,mBAAmB;EACvC,OAAO;CACT;CACA,IAAI,sBAAsB,cAAc,GAAG;EACzC,SAAS,UAAU,CAAC,CAAC,SAAS,UAAU,MAAM,KAAK,CAAC;EACpD,OAAO;CACT;CACA,MAAM,aAAa,iBAAiB;CACpC,IAAI;EACF,WAAW,OAAO,QAAQ;CAC5B,SAAS,KAAK;EAIZ,WAAW,SAAS;EACpB,MAAM;CACR;CACA,OAAO;EAAE;EAAY;CAAS;AAChC;AAuBA,SAAS,sBAAsB,MAA4C;CACzE,IAAI,WAAiC;CACrC,IAAI,SAAiB,CAAC;CACtB,IAAI,mBAAmB;CACvB,IAAI,aAAa;CACjB,IAAI,kBAAkB;CAEtB,SAAS,SAAe;EAGtB,MAAM,YAAY;EAClB,MAAM,MAAM;EACZ,MAAM,OAAO,IAAI,KAAK,QAAQ,EAAE,MAAM,sBAAsB,eAAe,EAAE,CAAC;EAC9E,KAAK,aAAa,MAAM,WAAW,GAAG;CACxC;CAEA,SAAS,UAAU,QAAqB,UAAkB,KAAmB;EAC3E,SAAS,CAAC;EACV,mBAAmB;EACnB,aAAa;EACb,kBAAkB;EAClB,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,OAAO;EACL,mBAAmB,UAAU,UAAU;EACvC,iBAAiB;EACjB,kBAAkB;GAChB,mBAAmB;EACrB;EACA;EACA,WAAW;GACT,IAAI,UAAU,UAAU,aAAa,SAAS,KAAK;EACrD;CACF;AACF;AAKA,IAAM,kBAA4C;CAChD,YAAY;CACZ,eAAe;CACf,UAAU;CACV,cAAc;CACd,cAAc;CACd,YAAY;CACZ,eAAe;AACjB;AAwBA,SAAS,YAAY,SAAyB,OAA+B,SAAgC;CAC3G,QAAQ,cAAc;CACtB,MAAM,aAAa,KAAK;CACxB,IAAI,QAAQ,kBAAkB,MAAM;EAClC,OAAO,cAAc,QAAQ,aAAa;EAC1C,QAAQ,gBAAgB;CAC1B;CACA,QAAQ,IAAI;CACZ,QAAQ,YAAY,SAAS;CAC7B,QAAQ,aAAa;AACvB;AAKA,SAAS,YAAY,SAAyB,SAAgC;CAC5E,IAAI,CAAC,QAAQ,YAAY,WAAW,GAAG;CACvC,MAAM,MAAM,WAAW,QAAQ,WAAW,OAAO,CAAC;CAClD,MAAM,EACJ,WACA,cAAc,aACd,QACE,YAAY;EAAE,WAAW,QAAQ,UAAU;EAAG,cAAc,QAAQ;CAAa,GAAG,QAAQ,cAAc,KAAK,KAAK,IAAI,GAAG,UAAU;CACzI,IAAI,WAAW,QAAQ,WAAW;CAClC,QAAQ,eAAe;CACvB,IAAI,KAAK,QAAQ,IAAI;AACvB;AAEA,SAAgB,mBAAmB,WAAkC,UAAwB,WAAgD;CAC3I,MAAM,QAAQ,mBAAmB,UAAU,OAAO;CAClD,MAAM,SAAS,yBAAyB,WAAW,MAAM,YAAY;CACrE,MAAM,UAA0B,EAAE,GAAG,gBAAgB;CACrD,MAAM,WAAW,mBAAmB;EAAE;EAAW;EAAU;EAAW,YAAY,MAAM;EAAY,qBAAqB,QAAQ;CAAW,CAAC;CAE7I,MAAM,UAAU,sBAAsB,EAAE,cAAc,iBAAiB,CAAC;CAExE,SAAS,iBAAiB,MAAY,WAAoB,KAAmB;EAG3E,IAAI,MAAM,YAAY,GAAG,cAAc;EACvC,IAAI,aAAa,KAAK,OAAO,KAAK,QAAQ,QAAQ,YAAY,SAAS,QAAQ,MAAM,GAAG;CAC1F;CAEA,SAAS,gBAAsB;EAC7B,MAAM,SAAS,QAAQ,YAAY,UAAU;EAC7C,IAAI,CAAC,QAAQ;EACb,QAAQ,eAAe;EACvB,QAAQ,eAAe,KAAK,IAAI;EAChC,QAAQ,UAAU,QAAQ,QAAQ,UAAU,QAAQ,UAAU;CAChE;CAEA,eAAe,QAA0B;EACvC,IAAI,QAAQ,iBAAiB,MAAM,YAAY,GAAG,OAAO;EACzD,QAAQ,gBAAgB;EACxB,MAAM,WAAW,QAAQ;EACzB,IAAI;GACF,MAAM,UAAU,MAAM,iBAAiB,WAAW,gBAAgB,QAAQ,UAAU;GACpF,IAAI,CAAC,SAAS,OAAO;GACrB,QAAQ,aAAa,QAAQ;GAC7B,QAAQ,WAAW,QAAQ;GAC3B,MAAM,aAAa,IAAI;GACvB,cAAc;GACd,QAAQ,gBAAgB,OAAO,kBAAkB,YAAY,SAAS,OAAO,GAAG,mBAAmB;GACnG,OAAO;EACT,UAAU;GACR,QAAQ,gBAAgB;EAC1B;CACF;CAEA,SAAS,OAAa;EACpB,YAAY,SAAS,OAAO,OAAO;CACrC;CAEA,SAAS,UAAgB;EACvB,OAAO,KAAK;EACZ,KAAK;CACP;CAEA,OAAO;EAAE,qBAAqB,OAAO;EAAS;EAAO;EAAM;CAAQ;AACrE"}
|
package/dist/wiki/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_dist = require("../dist-CsgSfWwR.cjs");
|
|
3
|
-
const require_graph = require("../graph-
|
|
3
|
+
const require_graph = require("../graph-BZJxiv1E.cjs");
|
|
4
4
|
const require_slug = require("../slug-DbwORN9z.cjs");
|
|
5
5
|
//#region src/wiki/route.ts
|
|
6
6
|
var WIKI_ROUTE_SECTION = {
|
|
@@ -100,7 +100,7 @@ function renderWikiLinks(content) {
|
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
-
out.push(content
|
|
103
|
+
out.push(content.charAt(i));
|
|
104
104
|
i++;
|
|
105
105
|
}
|
|
106
106
|
return out.join("");
|