@mulmoclaude/core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/assets/helps/collection-skills.md +52 -4
  2. package/assets/helps/feeds.md +16 -7
  3. package/assets/skills-preset/mc-manage-automations/SKILL.md +36 -2
  4. package/dist/collection/core/schema.d.ts +37 -10
  5. package/dist/collection/index.cjs +4 -3
  6. package/dist/collection/index.cjs.map +1 -1
  7. package/dist/collection/index.js +4 -4
  8. package/dist/collection/index.js.map +1 -1
  9. package/dist/collection/paths.cjs.map +1 -1
  10. package/dist/collection/paths.js.map +1 -1
  11. package/dist/collection/server/discovery.d.ts +14 -2
  12. package/dist/collection/server/index.cjs +1 -1
  13. package/dist/collection/server/index.js +1 -1
  14. package/dist/collection/server/io.d.ts +1 -1
  15. package/dist/collection-watchers/index.cjs +2 -3
  16. package/dist/collection-watchers/index.cjs.map +1 -1
  17. package/dist/collection-watchers/index.js +2 -2
  18. package/dist/collection-watchers/index.js.map +1 -1
  19. package/dist/{deriveAll-C15OpM3K.cjs → deriveAll-VRWrs3SF.cjs} +19 -5
  20. package/dist/deriveAll-VRWrs3SF.cjs.map +1 -0
  21. package/dist/{deriveAll-C6BYnpBL.js → deriveAll-vzIhhKBK.js} +14 -6
  22. package/dist/deriveAll-vzIhhKBK.js.map +1 -0
  23. package/dist/file-change/index.cjs +2 -2
  24. package/dist/file-change/index.cjs.map +1 -1
  25. package/dist/notifier/index.cjs +1 -1
  26. package/dist/notifier/index.js +1 -1
  27. package/dist/{notifier-6PjsLxLm.js → notifier-ChpY0XrY.js} +1 -1
  28. package/dist/{notifier-6PjsLxLm.js.map → notifier-ChpY0XrY.js.map} +1 -1
  29. package/dist/{notifier-lJ4v2Y6B.cjs → notifier-bS8IEeLA.cjs} +1 -2
  30. package/dist/{notifier-lJ4v2Y6B.cjs.map → notifier-bS8IEeLA.cjs.map} +1 -1
  31. package/dist/scheduler/index.cjs +2 -2
  32. package/dist/scheduler/index.cjs.map +1 -1
  33. package/dist/scheduler/index.js.map +1 -1
  34. package/dist/{server-BhIdZgqu.js → server-BPDCdvOI.js} +16 -7
  35. package/dist/server-BPDCdvOI.js.map +1 -0
  36. package/dist/{server-BjoKk2tR.cjs → server-Bego8VyU.cjs} +18 -9
  37. package/dist/server-Bego8VyU.cjs.map +1 -0
  38. package/dist/skill-bridge/index.cjs +2 -2
  39. package/dist/skill-bridge/index.cjs.map +1 -1
  40. package/dist/whisper/client.cjs +1 -1
  41. package/dist/whisper/client.cjs.map +1 -1
  42. package/dist/whisper/client.js +1 -1
  43. package/dist/whisper/client.js.map +1 -1
  44. package/dist/whisper/index.cjs +3 -3
  45. package/dist/whisper/index.cjs.map +1 -1
  46. package/dist/whisper/index.js +1 -1
  47. package/dist/whisper/index.js.map +1 -1
  48. package/dist/workspace-setup/index.js.map +1 -1
  49. package/package.json +3 -3
  50. package/dist/deriveAll-C15OpM3K.cjs.map +0 -1
  51. package/dist/deriveAll-C6BYnpBL.js.map +0 -1
  52. package/dist/server-BhIdZgqu.js.map +0 -1
  53. package/dist/server-BjoKk2tR.cjs.map +0 -1
  54. /package/dist/{chunk-CKQMccvm.cjs → rolldown-runtime-D6vf50IK.cjs} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_chunk = require("../chunk-CKQMccvm.cjs");
2
+ const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
3
3
  let node_path = require("node:path");
4
- node_path = require_chunk.__toESM(node_path, 1);
4
+ node_path = require_rolldown_runtime.__toESM(node_path, 1);
5
5
  let node_fs = require("node:fs");
6
6
  let node_fs_promises = require("node:fs/promises");
7
7
  let node_crypto = require("node:crypto");
@@ -382,7 +382,7 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
382
382
  }
383
383
  //#endregion
384
384
  //#region src/whisper/whisper.ts
385
- var BLANK_MARKERS = new Set([
385
+ var BLANK_MARKERS = /* @__PURE__ */ new Set([
386
386
  "[blank_audio]",
387
387
  "[silence]",
388
388
  "(silence)",
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.ts","../../src/whisper/sidecar.ts","../../src/whisper/whisper.ts"],"sourcesContent":["// Small self-contained utilities so the package has no host dependencies.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Minimal logger the host can inject; defaults to no-op so the package\n * is silent unless wired up. */\nexport interface WhisperLogger {\n info: (message: string, data?: unknown) => void;\n warn: (message: string, data?: unknown) => void;\n error: (message: string, data?: unknown) => void;\n}\n\nconst NOOP = (): void => undefined;\nexport const NOOP_LOGGER: WhisperLogger = { info: NOOP, warn: NOOP, error: NOOP };\n","// Thin wrapper around the system `ffmpeg` binary (provided by the host) for\n// converting a browser webm/opus clip to the 16 kHz mono 16-bit WAV whisper.cpp\n// requires.\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { ONE_MINUTE_MS } from \"./internal.ts\";\n\nconst execFileAsync = promisify(execFile);\n\n/** ffmpeg args to decode any input to 16 kHz mono signed-16-bit WAV. Pure +\n * exported for unit tests. */\nexport function buildWav16kArgs(inputPath: string, outputPath: string): string[] {\n return [\"-y\", \"-loglevel\", \"error\", \"-i\", inputPath, \"-ar\", \"16000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", outputPath];\n}\n\n/** Convert `inputPath` to a 16 kHz mono WAV at `outputPath`. Throws on ffmpeg\n * failure or timeout. */\nexport async function convertToWav16k(inputPath: string, outputPath: string, ffmpegBinary = \"ffmpeg\"): Promise<void> {\n await execFileAsync(ffmpegBinary, buildWav16kArgs(inputPath, outputPath), {\n timeout: ONE_MINUTE_MS,\n });\n}\n","// Whisper GGML model registry + on-disk management. The host injects the models\n// directory (e.g. `{workspace}/models`); nothing here reads a host module.\n\nimport { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { once } from \"node:events\";\nimport path from \"node:path\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, type WhisperLogger } from \"./internal.ts\";\n\nexport interface WhisperModelSpec {\n /** GGML filename — identical on disk and in the Hugging Face repo. */\n readonly file: string;\n /** Download URL (Hugging Face whisper.cpp model repo). */\n readonly url: string;\n /** Conservative lower bound on the finished file size, in bytes — guards\n * against a truncated transfer or an HTML error page saved as the model\n * without pinning an exact checksum. */\n readonly minBytes: number;\n}\n\nconst HF_BASE = \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main\";\n\n// large-v3-turbo: strong accuracy, near-real-time on Apple Silicon with Metal.\n// small/base are lighter fallbacks for low-RAM machines.\nexport const WHISPER_MODELS = {\n \"large-v3-turbo\": { file: \"ggml-large-v3-turbo.bin\", url: `${HF_BASE}/ggml-large-v3-turbo.bin`, minBytes: 1_000_000_000 },\n small: { file: \"ggml-small.bin\", url: `${HF_BASE}/ggml-small.bin`, minBytes: 300_000_000 },\n base: { file: \"ggml-base.bin\", url: `${HF_BASE}/ggml-base.bin`, minBytes: 100_000_000 },\n} as const satisfies Record<string, WhisperModelSpec>;\n\nexport type WhisperModelName = keyof typeof WHISPER_MODELS;\nexport const DEFAULT_WHISPER_MODEL: WhisperModelName = \"large-v3-turbo\";\n\nexport function isWhisperModelName(value: unknown): value is WhisperModelName {\n // Own-property check — `in` would accept inherited keys like \"toString\",\n // which then crash the `WHISPER_MODELS[name]` lookups instead of falling\n // back to the default.\n return typeof value === \"string\" && Object.prototype.hasOwnProperty.call(WHISPER_MODELS, value);\n}\n\n/** Resolve a possibly-unset / unknown model name to a valid one. */\nexport function resolveModelName(name: string | undefined): WhisperModelName {\n return isWhisperModelName(name) ? name : DEFAULT_WHISPER_MODEL;\n}\n\nexport function modelFilePath(modelsDir: string, name: WhisperModelName): string {\n return path.join(modelsDir, WHISPER_MODELS[name].file);\n}\n\n/** A model is \"ready\" when its file exists and meets the size floor. */\nexport function isModelReady(modelsDir: string, name: WhisperModelName): boolean {\n try {\n return statSync(modelFilePath(modelsDir, name)).size >= WHISPER_MODELS[name].minBytes;\n } catch {\n return false;\n }\n}\n\nexport type ModelDownloadState = \"idle\" | \"downloading\" | \"ready\" | \"error\";\n\nexport interface ModelStatus {\n state: ModelDownloadState;\n /** 0..1 — present only while downloading and Content-Length is known. */\n progress?: number;\n /** Present only in the \"error\" state. */\n error?: string;\n}\n\n// Abort a download if no bytes arrive for this long (stalled connection).\nconst DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;\n\nexport interface ModelDownloader {\n getStatus: (name: WhisperModelName) => ModelStatus;\n ensure: (name: WhisperModelName) => Promise<void>;\n}\n\nexport function createModelDownloader(modelsDir: string, logger: WhisperLogger = NOOP_LOGGER): ModelDownloader {\n // A finished file on disk is the source of truth for \"ready\"; this map tracks\n // transient progress + the last error.\n const downloadStatus = new Map<WhisperModelName, ModelStatus>();\n\n function getStatus(name: WhisperModelName): ModelStatus {\n const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\n }\n\n async function streamToFile(\n body: ReadableStream<Uint8Array>,\n partialPath: string,\n total: number,\n name: WhisperModelName,\n onProgress: () => void,\n ): Promise<void> {\n const reader = body.getReader();\n const fileStream = createWriteStream(partialPath);\n let received = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n onProgress();\n received += value.byteLength;\n if (!fileStream.write(value)) await once(fileStream, \"drain\");\n if (total > 0) downloadStatus.set(name, { state: \"downloading\", progress: received / total });\n }\n fileStream.end();\n await once(fileStream, \"finish\");\n } catch (err) {\n fileStream.destroy();\n throw err;\n }\n }\n\n async function download(name: WhisperModelName): Promise<void> {\n const spec = WHISPER_MODELS[name];\n mkdirSync(modelsDir, { recursive: true });\n const dest = modelFilePath(modelsDir, name);\n const partial = `${dest}.partial`;\n const controller = new AbortController();\n let stallTimer: ReturnType<typeof setTimeout> | null = null;\n const resetStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);\n };\n try {\n const response = await fetch(spec.url, { signal: controller.signal });\n if (!response.ok || !response.body) {\n throw new Error(`download failed: HTTP ${response.status}`);\n }\n const total = Number(response.headers.get(\"content-length\")) || 0;\n resetStall();\n await streamToFile(response.body, partial, total, name, resetStall);\n if (statSync(partial).size < spec.minBytes) {\n unlinkSync(partial);\n throw new Error(\"downloaded file is smaller than expected — likely truncated\");\n }\n renameSync(partial, dest);\n } finally {\n if (stallTimer) clearTimeout(stallTimer);\n }\n }\n\n // Fire-and-forget friendly: errors land in the status map, never thrown.\n // Idempotent — a second call while a download is in flight does nothing.\n async function ensure(name: WhisperModelName): Promise<void> {\n if (isModelReady(modelsDir, name)) {\n downloadStatus.set(name, { state: \"ready\" });\n return;\n }\n if (downloadStatus.get(name)?.state === \"downloading\") return;\n downloadStatus.set(name, { state: \"downloading\", progress: 0 });\n logger.info(\"model download: start\", { model: name });\n try {\n await download(name);\n downloadStatus.set(name, { state: \"ready\" });\n logger.info(\"model download: ok\", { model: name });\n } catch (err) {\n const error = errorMessage(err);\n downloadStatus.set(name, { state: \"error\", error });\n logger.error(\"model download: failed\", { model: name, error });\n }\n }\n\n return { getStatus, ensure };\n}\n","// whisper.cpp warm-model sidecar. Spawns `whisper-server` once with the model\n// preloaded and reuses it across transcriptions over its local HTTP API, so the\n// weights stay resident (no per-request reload). State is encapsulated per\n// instance via the factory closure.\n\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { createServer } from \"node:net\";\nimport { readFile } from \"node:fs/promises\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, ONE_SECOND_MS, type WhisperLogger } from \"./internal.ts\";\nimport { modelFilePath, type WhisperModelName } from \"./models.ts\";\n\nconst HOST = \"127.0.0.1\";\nconst READY_TIMEOUT_MS = 60 * ONE_SECOND_MS;\nconst READY_POLL_INTERVAL_MS = 500;\nconst INFERENCE_TIMEOUT_MS = 2 * ONE_MINUTE_MS;\n\ninterface ActiveSidecar {\n readonly port: number;\n readonly proc: ChildProcess;\n readonly model: WhisperModelName;\n}\n\nexport interface Sidecar {\n transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;\n warmup: (model: WhisperModelName) => Promise<void>;\n shutdown: () => void;\n}\n\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = createServer();\n srv.on(\"error\", reject);\n srv.listen(0, HOST, () => {\n const addr = srv.address();\n if (addr && typeof addr === \"object\") {\n const { port } = addr;\n srv.close(() => resolve(port));\n } else {\n srv.close(() => reject(new Error(\"could not determine a free port\")));\n }\n });\n });\n}\n\n/** Resolve once the server answers any HTTP request (any status = listener up),\n * or throw after the ready timeout. */\nasync function waitUntilReady(port: number): Promise<void> {\n const deadline = Date.now() + READY_TIMEOUT_MS;\n while (Date.now() < deadline) {\n try {\n await fetch(`http://${HOST}:${port}/`, { signal: AbortSignal.timeout(ONE_SECOND_MS) });\n return;\n } catch {\n await delay(READY_POLL_INTERVAL_MS);\n }\n }\n throw new Error(\"whisper-server did not become ready in time\");\n}\n\n// whisper-server logs verbosely to stderr; left unread the OS pipe buffer fills\n// and the child blocks on its next write. Drain into a small tail buffer.\nfunction drainStderr(proc: ChildProcess, tail: { text: string }): void {\n proc.stderr?.setEncoding(\"utf8\");\n proc.stderr?.on(\"data\", (chunk: string) => {\n tail.text = (tail.text + chunk).slice(-4000);\n });\n}\n\n// Resolve when the server answers, or reject on spawn failure (e.g. ENOENT) /\n// early exit. Listeners are one-shot and removed once the race settles.\nfunction waitForReadyOrFailure(proc: ChildProcess, port: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let onError: (err: Error) => void = () => undefined;\n let onExit: (code: number | null) => void = () => undefined;\n const cleanup = () => {\n proc.removeListener(\"error\", onError);\n proc.removeListener(\"exit\", onExit);\n };\n onError = (err: Error) => {\n cleanup();\n reject(new Error(`spawn failed: ${errorMessage(err)}`));\n };\n onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`exited early (code ${code})`));\n };\n proc.once(\"error\", onError);\n proc.once(\"exit\", onExit);\n waitUntilReady(port)\n .then(() => {\n cleanup();\n resolve();\n })\n .catch((err: unknown) => {\n cleanup();\n reject(err instanceof Error ? err : new Error(String(err)));\n });\n });\n}\n\nfunction parseInferenceText(data: unknown): string {\n if (typeof data === \"object\" && data !== null && \"text\" in data) {\n const { text } = data as { text: unknown };\n if (typeof text === \"string\") return text;\n }\n return \"\";\n}\n\nexport function createSidecar(modelsDir: string, serverBinary = \"whisper-server\", logger: WhisperLogger = NOOP_LOGGER): Sidecar {\n let sidecar: ActiveSidecar | null = null;\n let starting: { model: WhisperModelName; promise: Promise<ActiveSidecar> } | null = null;\n // The child of an in-flight start (before it's published as `sidecar`), plus a\n // token that `shutdown()` bumps to cancel a start that's still booting — so\n // shutdown can't return with a child that then publishes itself afterwards.\n let startingProc: ChildProcess | null = null;\n let startToken = 0;\n\n function shutdown(): void {\n startToken += 1;\n if (startingProc) {\n startingProc.kill();\n startingProc = null;\n }\n if (sidecar) {\n sidecar.proc.kill();\n sidecar = null;\n }\n }\n\n async function startSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n const token = ++startToken;\n const port = await findFreePort();\n const args = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(port)];\n logger.info(\"sidecar: spawning\", { model, port });\n const proc = spawn(serverBinary, args, { stdio: [\"ignore\", \"ignore\", \"pipe\"] });\n startingProc = proc;\n const stderrTail = { text: \"\" };\n drainStderr(proc, stderrTail);\n // Permanent error listener — a missing one would let a process 'error'\n // (e.g. ENOENT) throw uncaught and crash the host.\n proc.on(\"error\", (err) => logger.warn(\"sidecar: process error\", { model, error: errorMessage(err) }));\n proc.on(\"exit\", (code) => {\n logger.warn(\"sidecar: exited\", { model, code, stderrTail: stderrTail.text.slice(-500) });\n if (sidecar?.proc === proc) sidecar = null;\n });\n try {\n await waitForReadyOrFailure(proc, port);\n } catch (err) {\n proc.kill();\n throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);\n } finally {\n if (startingProc === proc) startingProc = null;\n }\n // shutdown() (or a newer start) ran while we were booting — discard this\n // child instead of publishing a sidecar after shutdown returned.\n // eslint-disable-next-line security/detect-possible-timing-attacks -- in-memory start-cancellation token, not an auth compare\n if (token !== startToken) {\n proc.kill();\n throw new Error(\"whisper-server start cancelled\");\n }\n sidecar = { port, proc, model };\n logger.info(\"sidecar: ready\", { model, port });\n return sidecar;\n }\n\n // Module-scoped spawn so it isn't a loop closure (no-loop-func). Only ever one\n // start is in flight at a time, so clearing `starting` on settle is safe.\n function beginStart(model: WhisperModelName): Promise<ActiveSidecar> {\n const promise = startSidecar(model).finally(() => {\n starting = null;\n });\n starting = { model, promise };\n return promise;\n }\n\n async function ensureSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n // Loop so that after awaiting an in-flight start for a DIFFERENT model we\n // re-evaluate; the decision-to-spawn path (beginStart) has no await, so the\n // first waiter sets `starting` synchronously and siblings then reuse it.\n for (;;) {\n if (sidecar && sidecar.model === model && !sidecar.proc.killed) return sidecar;\n if (starting && starting.model === model) return starting.promise;\n if (starting) {\n await starting.promise.catch(() => undefined);\n continue;\n }\n if (sidecar && sidecar.model !== model) shutdown();\n return beginStart(model);\n }\n }\n\n async function warmup(model: WhisperModelName): Promise<void> {\n try {\n await ensureSidecar(model);\n } catch (err) {\n logger.warn(\"sidecar: warmup failed\", { model, error: errorMessage(err) });\n }\n }\n\n async function transcribeWav(wavPath: string, language: string, model: WhisperModelName): Promise<string> {\n const active = await ensureSidecar(model);\n const buf = await readFile(wavPath);\n const form = new FormData();\n form.append(\"file\", new Blob([buf], { type: \"audio/wav\" }), \"audio.wav\");\n form.append(\"response_format\", \"json\");\n form.append(\"language\", language || \"auto\");\n let res: Response;\n try {\n res = await fetch(`http://${HOST}:${active.port}/inference`, { method: \"POST\", body: form, signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS) });\n } catch (err) {\n throw new Error(`whisper-server request failed: ${errorMessage(err)}`);\n }\n if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);\n return parseInferenceText(await res.json());\n }\n\n return { transcribeWav, warmup, shutdown };\n}\n","// Public server-side façade: wires the model downloader, the warm sidecar, and\n// ffmpeg conversion into one host-agnostic service. The host injects the models\n// directory + a logger and gates capability itself (platform / binary presence);\n// this package assumes the binaries exist when called.\n\nimport { mkdirSync } from \"node:fs\";\nimport { rm, writeFile } from \"node:fs/promises\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { NOOP_LOGGER, type WhisperLogger } from \"./internal.ts\";\nimport { convertToWav16k } from \"./ffmpeg.ts\";\nimport { createModelDownloader } from \"./models.ts\";\nimport { createSidecar } from \"./sidecar.ts\";\nimport { isModelReady, type ModelStatus, type WhisperModelName } from \"./models.ts\";\n\nexport interface WhisperOptions {\n /** Directory that holds the GGML model files (e.g. `{workspace}/models`). */\n modelsDir: string;\n logger?: WhisperLogger;\n /** Defaults to \"whisper-server\" / \"ffmpeg\" on PATH. */\n serverBinary?: string;\n ffmpegBinary?: string;\n}\n\nexport interface TranscribeRequest {\n base64: string;\n mimeType: string;\n language: string;\n model: WhisperModelName;\n}\n\nexport interface Whisper {\n isModelReady: (model: WhisperModelName) => boolean;\n getModelStatus: (model: WhisperModelName) => ModelStatus;\n /** Fire-and-forget friendly; never throws (errors land in the status). */\n ensureModelDownloaded: (model: WhisperModelName) => Promise<void>;\n warmup: (model: WhisperModelName) => Promise<void>;\n transcribe: (req: TranscribeRequest) => Promise<{ text: string }>;\n shutdown: () => void;\n}\n\n// whisper.cpp returns these sentinels for non-speech windows; treat them as\n// empty so the UI shows \"didn't catch that\" rather than a literal marker.\nconst BLANK_MARKERS = new Set([\"[blank_audio]\", \"[silence]\", \"(silence)\", \"[ inaudible ]\"]);\n\nfunction normalizeTranscript(raw: string): string {\n const trimmed = raw.trim().replace(/\\s+/g, \" \");\n return BLANK_MARKERS.has(trimmed.toLowerCase()) ? \"\" : trimmed;\n}\n\nexport function createWhisper(opts: WhisperOptions): Whisper {\n const { modelsDir } = opts;\n const logger = opts.logger ?? NOOP_LOGGER;\n const ffmpegBinary = opts.ffmpegBinary ?? \"ffmpeg\";\n const downloader = createModelDownloader(modelsDir, logger);\n const sidecar = createSidecar(modelsDir, opts.serverBinary ?? \"whisper-server\", logger);\n\n // Scratch dir for transient audio — a hidden subdir of the models dir so it\n // shares the (non-git) models tree. Files are deleted after each transcription.\n const scratchDir = path.join(modelsDir, \".scratch\");\n\n async function transcribe(req: TranscribeRequest): Promise<{ text: string }> {\n mkdirSync(scratchDir, { recursive: true });\n const clipId = randomUUID();\n const inputPath = path.join(scratchDir, `utterance-${clipId}.webm`);\n const wavPath = path.join(scratchDir, `utterance-${clipId}.wav`);\n try {\n await writeFile(inputPath, Buffer.from(req.base64, \"base64\"));\n await convertToWav16k(inputPath, wavPath, ffmpegBinary);\n const text = await sidecar.transcribeWav(wavPath, req.language, req.model);\n return { text: normalizeTranscript(text) };\n } finally {\n await rm(inputPath, { force: true });\n await rm(wavPath, { force: true });\n }\n }\n\n return {\n isModelReady: (model) => isModelReady(modelsDir, model),\n getModelStatus: (model) => downloader.getStatus(model),\n ensureModelDownloaded: (model) => downloader.ensure(model),\n warmup: (model) => sidecar.warmup(model),\n transcribe,\n shutdown: () => sidecar.shutdown(),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.ts","../../src/whisper/sidecar.ts","../../src/whisper/whisper.ts"],"sourcesContent":["// Small self-contained utilities so the package has no host dependencies.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Minimal logger the host can inject; defaults to no-op so the package\n * is silent unless wired up. */\nexport interface WhisperLogger {\n info: (message: string, data?: unknown) => void;\n warn: (message: string, data?: unknown) => void;\n error: (message: string, data?: unknown) => void;\n}\n\nconst NOOP = (): void => undefined;\nexport const NOOP_LOGGER: WhisperLogger = { info: NOOP, warn: NOOP, error: NOOP };\n","// Thin wrapper around the system `ffmpeg` binary (provided by the host) for\n// converting a browser webm/opus clip to the 16 kHz mono 16-bit WAV whisper.cpp\n// requires.\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { ONE_MINUTE_MS } from \"./internal.ts\";\n\nconst execFileAsync = promisify(execFile);\n\n/** ffmpeg args to decode any input to 16 kHz mono signed-16-bit WAV. Pure +\n * exported for unit tests. */\nexport function buildWav16kArgs(inputPath: string, outputPath: string): string[] {\n return [\"-y\", \"-loglevel\", \"error\", \"-i\", inputPath, \"-ar\", \"16000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", outputPath];\n}\n\n/** Convert `inputPath` to a 16 kHz mono WAV at `outputPath`. Throws on ffmpeg\n * failure or timeout. */\nexport async function convertToWav16k(inputPath: string, outputPath: string, ffmpegBinary = \"ffmpeg\"): Promise<void> {\n await execFileAsync(ffmpegBinary, buildWav16kArgs(inputPath, outputPath), {\n timeout: ONE_MINUTE_MS,\n });\n}\n","// Whisper GGML model registry + on-disk management. The host injects the models\n// directory (e.g. `{workspace}/models`); nothing here reads a host module.\n\nimport { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { once } from \"node:events\";\nimport path from \"node:path\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, type WhisperLogger } from \"./internal.ts\";\n\nexport interface WhisperModelSpec {\n /** GGML filename — identical on disk and in the Hugging Face repo. */\n readonly file: string;\n /** Download URL (Hugging Face whisper.cpp model repo). */\n readonly url: string;\n /** Conservative lower bound on the finished file size, in bytes — guards\n * against a truncated transfer or an HTML error page saved as the model\n * without pinning an exact checksum. */\n readonly minBytes: number;\n}\n\nconst HF_BASE = \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main\";\n\n// large-v3-turbo: strong accuracy, near-real-time on Apple Silicon with Metal.\n// small/base are lighter fallbacks for low-RAM machines.\nexport const WHISPER_MODELS = {\n \"large-v3-turbo\": { file: \"ggml-large-v3-turbo.bin\", url: `${HF_BASE}/ggml-large-v3-turbo.bin`, minBytes: 1_000_000_000 },\n small: { file: \"ggml-small.bin\", url: `${HF_BASE}/ggml-small.bin`, minBytes: 300_000_000 },\n base: { file: \"ggml-base.bin\", url: `${HF_BASE}/ggml-base.bin`, minBytes: 100_000_000 },\n} as const satisfies Record<string, WhisperModelSpec>;\n\nexport type WhisperModelName = keyof typeof WHISPER_MODELS;\nexport const DEFAULT_WHISPER_MODEL: WhisperModelName = \"large-v3-turbo\";\n\nexport function isWhisperModelName(value: unknown): value is WhisperModelName {\n // Own-property check — `in` would accept inherited keys like \"toString\",\n // which then crash the `WHISPER_MODELS[name]` lookups instead of falling\n // back to the default.\n return typeof value === \"string\" && Object.prototype.hasOwnProperty.call(WHISPER_MODELS, value);\n}\n\n/** Resolve a possibly-unset / unknown model name to a valid one. */\nexport function resolveModelName(name: string | undefined): WhisperModelName {\n return isWhisperModelName(name) ? name : DEFAULT_WHISPER_MODEL;\n}\n\nexport function modelFilePath(modelsDir: string, name: WhisperModelName): string {\n return path.join(modelsDir, WHISPER_MODELS[name].file);\n}\n\n/** A model is \"ready\" when its file exists and meets the size floor. */\nexport function isModelReady(modelsDir: string, name: WhisperModelName): boolean {\n try {\n return statSync(modelFilePath(modelsDir, name)).size >= WHISPER_MODELS[name].minBytes;\n } catch {\n return false;\n }\n}\n\nexport type ModelDownloadState = \"idle\" | \"downloading\" | \"ready\" | \"error\";\n\nexport interface ModelStatus {\n state: ModelDownloadState;\n /** 0..1 — present only while downloading and Content-Length is known. */\n progress?: number;\n /** Present only in the \"error\" state. */\n error?: string;\n}\n\n// Abort a download if no bytes arrive for this long (stalled connection).\nconst DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;\n\nexport interface ModelDownloader {\n getStatus: (name: WhisperModelName) => ModelStatus;\n ensure: (name: WhisperModelName) => Promise<void>;\n}\n\nexport function createModelDownloader(modelsDir: string, logger: WhisperLogger = NOOP_LOGGER): ModelDownloader {\n // A finished file on disk is the source of truth for \"ready\"; this map tracks\n // transient progress + the last error.\n const downloadStatus = new Map<WhisperModelName, ModelStatus>();\n\n function getStatus(name: WhisperModelName): ModelStatus {\n const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\n }\n\n async function streamToFile(\n body: ReadableStream<Uint8Array>,\n partialPath: string,\n total: number,\n name: WhisperModelName,\n onProgress: () => void,\n ): Promise<void> {\n const reader = body.getReader();\n const fileStream = createWriteStream(partialPath);\n let received = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n onProgress();\n received += value.byteLength;\n if (!fileStream.write(value)) await once(fileStream, \"drain\");\n if (total > 0) downloadStatus.set(name, { state: \"downloading\", progress: received / total });\n }\n fileStream.end();\n await once(fileStream, \"finish\");\n } catch (err) {\n fileStream.destroy();\n throw err;\n }\n }\n\n async function download(name: WhisperModelName): Promise<void> {\n const spec = WHISPER_MODELS[name];\n mkdirSync(modelsDir, { recursive: true });\n const dest = modelFilePath(modelsDir, name);\n const partial = `${dest}.partial`;\n const controller = new AbortController();\n let stallTimer: ReturnType<typeof setTimeout> | null = null;\n const resetStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);\n };\n try {\n const response = await fetch(spec.url, { signal: controller.signal });\n if (!response.ok || !response.body) {\n throw new Error(`download failed: HTTP ${response.status}`);\n }\n const total = Number(response.headers.get(\"content-length\")) || 0;\n resetStall();\n await streamToFile(response.body, partial, total, name, resetStall);\n if (statSync(partial).size < spec.minBytes) {\n unlinkSync(partial);\n throw new Error(\"downloaded file is smaller than expected — likely truncated\");\n }\n renameSync(partial, dest);\n } finally {\n if (stallTimer) clearTimeout(stallTimer);\n }\n }\n\n // Fire-and-forget friendly: errors land in the status map, never thrown.\n // Idempotent — a second call while a download is in flight does nothing.\n async function ensure(name: WhisperModelName): Promise<void> {\n if (isModelReady(modelsDir, name)) {\n downloadStatus.set(name, { state: \"ready\" });\n return;\n }\n if (downloadStatus.get(name)?.state === \"downloading\") return;\n downloadStatus.set(name, { state: \"downloading\", progress: 0 });\n logger.info(\"model download: start\", { model: name });\n try {\n await download(name);\n downloadStatus.set(name, { state: \"ready\" });\n logger.info(\"model download: ok\", { model: name });\n } catch (err) {\n const error = errorMessage(err);\n downloadStatus.set(name, { state: \"error\", error });\n logger.error(\"model download: failed\", { model: name, error });\n }\n }\n\n return { getStatus, ensure };\n}\n","// whisper.cpp warm-model sidecar. Spawns `whisper-server` once with the model\n// preloaded and reuses it across transcriptions over its local HTTP API, so the\n// weights stay resident (no per-request reload). State is encapsulated per\n// instance via the factory closure.\n\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { createServer } from \"node:net\";\nimport { readFile } from \"node:fs/promises\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, ONE_SECOND_MS, type WhisperLogger } from \"./internal.ts\";\nimport { modelFilePath, type WhisperModelName } from \"./models.ts\";\n\nconst HOST = \"127.0.0.1\";\nconst READY_TIMEOUT_MS = 60 * ONE_SECOND_MS;\nconst READY_POLL_INTERVAL_MS = 500;\nconst INFERENCE_TIMEOUT_MS = 2 * ONE_MINUTE_MS;\n\ninterface ActiveSidecar {\n readonly port: number;\n readonly proc: ChildProcess;\n readonly model: WhisperModelName;\n}\n\nexport interface Sidecar {\n transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;\n warmup: (model: WhisperModelName) => Promise<void>;\n shutdown: () => void;\n}\n\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = createServer();\n srv.on(\"error\", reject);\n srv.listen(0, HOST, () => {\n const addr = srv.address();\n if (addr && typeof addr === \"object\") {\n const { port } = addr;\n srv.close(() => resolve(port));\n } else {\n srv.close(() => reject(new Error(\"could not determine a free port\")));\n }\n });\n });\n}\n\n/** Resolve once the server answers any HTTP request (any status = listener up),\n * or throw after the ready timeout. */\nasync function waitUntilReady(port: number): Promise<void> {\n const deadline = Date.now() + READY_TIMEOUT_MS;\n while (Date.now() < deadline) {\n try {\n await fetch(`http://${HOST}:${port}/`, { signal: AbortSignal.timeout(ONE_SECOND_MS) });\n return;\n } catch {\n await delay(READY_POLL_INTERVAL_MS);\n }\n }\n throw new Error(\"whisper-server did not become ready in time\");\n}\n\n// whisper-server logs verbosely to stderr; left unread the OS pipe buffer fills\n// and the child blocks on its next write. Drain into a small tail buffer.\nfunction drainStderr(proc: ChildProcess, tail: { text: string }): void {\n proc.stderr?.setEncoding(\"utf8\");\n proc.stderr?.on(\"data\", (chunk: string) => {\n tail.text = (tail.text + chunk).slice(-4000);\n });\n}\n\n// Resolve when the server answers, or reject on spawn failure (e.g. ENOENT) /\n// early exit. Listeners are one-shot and removed once the race settles.\nfunction waitForReadyOrFailure(proc: ChildProcess, port: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let onError: (err: Error) => void = () => undefined;\n let onExit: (code: number | null) => void = () => undefined;\n const cleanup = () => {\n proc.removeListener(\"error\", onError);\n proc.removeListener(\"exit\", onExit);\n };\n onError = (err: Error) => {\n cleanup();\n reject(new Error(`spawn failed: ${errorMessage(err)}`));\n };\n onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`exited early (code ${code})`));\n };\n proc.once(\"error\", onError);\n proc.once(\"exit\", onExit);\n waitUntilReady(port)\n .then(() => {\n cleanup();\n resolve();\n })\n .catch((err: unknown) => {\n cleanup();\n reject(err instanceof Error ? err : new Error(String(err)));\n });\n });\n}\n\nfunction parseInferenceText(data: unknown): string {\n if (typeof data === \"object\" && data !== null && \"text\" in data) {\n const { text } = data as { text: unknown };\n if (typeof text === \"string\") return text;\n }\n return \"\";\n}\n\nexport function createSidecar(modelsDir: string, serverBinary = \"whisper-server\", logger: WhisperLogger = NOOP_LOGGER): Sidecar {\n let sidecar: ActiveSidecar | null = null;\n let starting: { model: WhisperModelName; promise: Promise<ActiveSidecar> } | null = null;\n // The child of an in-flight start (before it's published as `sidecar`), plus a\n // token that `shutdown()` bumps to cancel a start that's still booting — so\n // shutdown can't return with a child that then publishes itself afterwards.\n let startingProc: ChildProcess | null = null;\n let startToken = 0;\n\n function shutdown(): void {\n startToken += 1;\n if (startingProc) {\n startingProc.kill();\n startingProc = null;\n }\n if (sidecar) {\n sidecar.proc.kill();\n sidecar = null;\n }\n }\n\n async function startSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n const token = ++startToken;\n const port = await findFreePort();\n const args = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(port)];\n logger.info(\"sidecar: spawning\", { model, port });\n const proc = spawn(serverBinary, args, { stdio: [\"ignore\", \"ignore\", \"pipe\"] });\n startingProc = proc;\n const stderrTail = { text: \"\" };\n drainStderr(proc, stderrTail);\n // Permanent error listener — a missing one would let a process 'error'\n // (e.g. ENOENT) throw uncaught and crash the host.\n proc.on(\"error\", (err) => logger.warn(\"sidecar: process error\", { model, error: errorMessage(err) }));\n proc.on(\"exit\", (code) => {\n logger.warn(\"sidecar: exited\", { model, code, stderrTail: stderrTail.text.slice(-500) });\n if (sidecar?.proc === proc) sidecar = null;\n });\n try {\n await waitForReadyOrFailure(proc, port);\n } catch (err) {\n proc.kill();\n throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);\n } finally {\n if (startingProc === proc) startingProc = null;\n }\n // shutdown() (or a newer start) ran while we were booting — discard this\n // child instead of publishing a sidecar after shutdown returned.\n // eslint-disable-next-line security/detect-possible-timing-attacks -- in-memory start-cancellation token, not an auth compare\n if (token !== startToken) {\n proc.kill();\n throw new Error(\"whisper-server start cancelled\");\n }\n sidecar = { port, proc, model };\n logger.info(\"sidecar: ready\", { model, port });\n return sidecar;\n }\n\n // Module-scoped spawn so it isn't a loop closure (no-loop-func). Only ever one\n // start is in flight at a time, so clearing `starting` on settle is safe.\n function beginStart(model: WhisperModelName): Promise<ActiveSidecar> {\n const promise = startSidecar(model).finally(() => {\n starting = null;\n });\n starting = { model, promise };\n return promise;\n }\n\n async function ensureSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n // Loop so that after awaiting an in-flight start for a DIFFERENT model we\n // re-evaluate; the decision-to-spawn path (beginStart) has no await, so the\n // first waiter sets `starting` synchronously and siblings then reuse it.\n for (;;) {\n if (sidecar && sidecar.model === model && !sidecar.proc.killed) return sidecar;\n if (starting && starting.model === model) return starting.promise;\n if (starting) {\n await starting.promise.catch(() => undefined);\n continue;\n }\n if (sidecar && sidecar.model !== model) shutdown();\n return beginStart(model);\n }\n }\n\n async function warmup(model: WhisperModelName): Promise<void> {\n try {\n await ensureSidecar(model);\n } catch (err) {\n logger.warn(\"sidecar: warmup failed\", { model, error: errorMessage(err) });\n }\n }\n\n async function transcribeWav(wavPath: string, language: string, model: WhisperModelName): Promise<string> {\n const active = await ensureSidecar(model);\n const buf = await readFile(wavPath);\n const form = new FormData();\n form.append(\"file\", new Blob([buf], { type: \"audio/wav\" }), \"audio.wav\");\n form.append(\"response_format\", \"json\");\n form.append(\"language\", language || \"auto\");\n let res: Response;\n try {\n res = await fetch(`http://${HOST}:${active.port}/inference`, { method: \"POST\", body: form, signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS) });\n } catch (err) {\n throw new Error(`whisper-server request failed: ${errorMessage(err)}`);\n }\n if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);\n return parseInferenceText(await res.json());\n }\n\n return { transcribeWav, warmup, shutdown };\n}\n","// Public server-side façade: wires the model downloader, the warm sidecar, and\n// ffmpeg conversion into one host-agnostic service. The host injects the models\n// directory + a logger and gates capability itself (platform / binary presence);\n// this package assumes the binaries exist when called.\n\nimport { mkdirSync } from \"node:fs\";\nimport { rm, writeFile } from \"node:fs/promises\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { NOOP_LOGGER, type WhisperLogger } from \"./internal.ts\";\nimport { convertToWav16k } from \"./ffmpeg.ts\";\nimport { createModelDownloader } from \"./models.ts\";\nimport { createSidecar } from \"./sidecar.ts\";\nimport { isModelReady, type ModelStatus, type WhisperModelName } from \"./models.ts\";\n\nexport interface WhisperOptions {\n /** Directory that holds the GGML model files (e.g. `{workspace}/models`). */\n modelsDir: string;\n logger?: WhisperLogger;\n /** Defaults to \"whisper-server\" / \"ffmpeg\" on PATH. */\n serverBinary?: string;\n ffmpegBinary?: string;\n}\n\nexport interface TranscribeRequest {\n base64: string;\n mimeType: string;\n language: string;\n model: WhisperModelName;\n}\n\nexport interface Whisper {\n isModelReady: (model: WhisperModelName) => boolean;\n getModelStatus: (model: WhisperModelName) => ModelStatus;\n /** Fire-and-forget friendly; never throws (errors land in the status). */\n ensureModelDownloaded: (model: WhisperModelName) => Promise<void>;\n warmup: (model: WhisperModelName) => Promise<void>;\n transcribe: (req: TranscribeRequest) => Promise<{ text: string }>;\n shutdown: () => void;\n}\n\n// whisper.cpp returns these sentinels for non-speech windows; treat them as\n// empty so the UI shows \"didn't catch that\" rather than a literal marker.\nconst BLANK_MARKERS = new Set([\"[blank_audio]\", \"[silence]\", \"(silence)\", \"[ inaudible ]\"]);\n\nfunction normalizeTranscript(raw: string): string {\n const trimmed = raw.trim().replace(/\\s+/g, \" \");\n return BLANK_MARKERS.has(trimmed.toLowerCase()) ? \"\" : trimmed;\n}\n\nexport function createWhisper(opts: WhisperOptions): Whisper {\n const { modelsDir } = opts;\n const logger = opts.logger ?? NOOP_LOGGER;\n const ffmpegBinary = opts.ffmpegBinary ?? \"ffmpeg\";\n const downloader = createModelDownloader(modelsDir, logger);\n const sidecar = createSidecar(modelsDir, opts.serverBinary ?? \"whisper-server\", logger);\n\n // Scratch dir for transient audio — a hidden subdir of the models dir so it\n // shares the (non-git) models tree. Files are deleted after each transcription.\n const scratchDir = path.join(modelsDir, \".scratch\");\n\n async function transcribe(req: TranscribeRequest): Promise<{ text: string }> {\n mkdirSync(scratchDir, { recursive: true });\n const clipId = randomUUID();\n const inputPath = path.join(scratchDir, `utterance-${clipId}.webm`);\n const wavPath = path.join(scratchDir, `utterance-${clipId}.wav`);\n try {\n await writeFile(inputPath, Buffer.from(req.base64, \"base64\"));\n await convertToWav16k(inputPath, wavPath, ffmpegBinary);\n const text = await sidecar.transcribeWav(wavPath, req.language, req.model);\n return { text: normalizeTranscript(text) };\n } finally {\n await rm(inputPath, { force: true });\n await rm(wavPath, { force: true });\n }\n }\n\n return {\n isModelReady: (model) => isModelReady(modelsDir, model),\n getModelStatus: (model) => downloader.getStatus(model),\n ensureModelDownloaded: (model) => downloader.ensure(model),\n warmup: (model) => sidecar.warmup(model),\n transcribe,\n shutdown: () => sidecar.shutdown(),\n };\n}\n"],"mappings":"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"}
@@ -379,7 +379,7 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
379
379
  }
380
380
  //#endregion
381
381
  //#region src/whisper/whisper.ts
382
- var BLANK_MARKERS = new Set([
382
+ var BLANK_MARKERS = /* @__PURE__ */ new Set([
383
383
  "[blank_audio]",
384
384
  "[silence]",
385
385
  "(silence)",
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.ts","../../src/whisper/sidecar.ts","../../src/whisper/whisper.ts"],"sourcesContent":["// Small self-contained utilities so the package has no host dependencies.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Minimal logger the host can inject; defaults to no-op so the package\n * is silent unless wired up. */\nexport interface WhisperLogger {\n info: (message: string, data?: unknown) => void;\n warn: (message: string, data?: unknown) => void;\n error: (message: string, data?: unknown) => void;\n}\n\nconst NOOP = (): void => undefined;\nexport const NOOP_LOGGER: WhisperLogger = { info: NOOP, warn: NOOP, error: NOOP };\n","// Thin wrapper around the system `ffmpeg` binary (provided by the host) for\n// converting a browser webm/opus clip to the 16 kHz mono 16-bit WAV whisper.cpp\n// requires.\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { ONE_MINUTE_MS } from \"./internal.ts\";\n\nconst execFileAsync = promisify(execFile);\n\n/** ffmpeg args to decode any input to 16 kHz mono signed-16-bit WAV. Pure +\n * exported for unit tests. */\nexport function buildWav16kArgs(inputPath: string, outputPath: string): string[] {\n return [\"-y\", \"-loglevel\", \"error\", \"-i\", inputPath, \"-ar\", \"16000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", outputPath];\n}\n\n/** Convert `inputPath` to a 16 kHz mono WAV at `outputPath`. Throws on ffmpeg\n * failure or timeout. */\nexport async function convertToWav16k(inputPath: string, outputPath: string, ffmpegBinary = \"ffmpeg\"): Promise<void> {\n await execFileAsync(ffmpegBinary, buildWav16kArgs(inputPath, outputPath), {\n timeout: ONE_MINUTE_MS,\n });\n}\n","// Whisper GGML model registry + on-disk management. The host injects the models\n// directory (e.g. `{workspace}/models`); nothing here reads a host module.\n\nimport { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { once } from \"node:events\";\nimport path from \"node:path\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, type WhisperLogger } from \"./internal.ts\";\n\nexport interface WhisperModelSpec {\n /** GGML filename — identical on disk and in the Hugging Face repo. */\n readonly file: string;\n /** Download URL (Hugging Face whisper.cpp model repo). */\n readonly url: string;\n /** Conservative lower bound on the finished file size, in bytes — guards\n * against a truncated transfer or an HTML error page saved as the model\n * without pinning an exact checksum. */\n readonly minBytes: number;\n}\n\nconst HF_BASE = \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main\";\n\n// large-v3-turbo: strong accuracy, near-real-time on Apple Silicon with Metal.\n// small/base are lighter fallbacks for low-RAM machines.\nexport const WHISPER_MODELS = {\n \"large-v3-turbo\": { file: \"ggml-large-v3-turbo.bin\", url: `${HF_BASE}/ggml-large-v3-turbo.bin`, minBytes: 1_000_000_000 },\n small: { file: \"ggml-small.bin\", url: `${HF_BASE}/ggml-small.bin`, minBytes: 300_000_000 },\n base: { file: \"ggml-base.bin\", url: `${HF_BASE}/ggml-base.bin`, minBytes: 100_000_000 },\n} as const satisfies Record<string, WhisperModelSpec>;\n\nexport type WhisperModelName = keyof typeof WHISPER_MODELS;\nexport const DEFAULT_WHISPER_MODEL: WhisperModelName = \"large-v3-turbo\";\n\nexport function isWhisperModelName(value: unknown): value is WhisperModelName {\n // Own-property check — `in` would accept inherited keys like \"toString\",\n // which then crash the `WHISPER_MODELS[name]` lookups instead of falling\n // back to the default.\n return typeof value === \"string\" && Object.prototype.hasOwnProperty.call(WHISPER_MODELS, value);\n}\n\n/** Resolve a possibly-unset / unknown model name to a valid one. */\nexport function resolveModelName(name: string | undefined): WhisperModelName {\n return isWhisperModelName(name) ? name : DEFAULT_WHISPER_MODEL;\n}\n\nexport function modelFilePath(modelsDir: string, name: WhisperModelName): string {\n return path.join(modelsDir, WHISPER_MODELS[name].file);\n}\n\n/** A model is \"ready\" when its file exists and meets the size floor. */\nexport function isModelReady(modelsDir: string, name: WhisperModelName): boolean {\n try {\n return statSync(modelFilePath(modelsDir, name)).size >= WHISPER_MODELS[name].minBytes;\n } catch {\n return false;\n }\n}\n\nexport type ModelDownloadState = \"idle\" | \"downloading\" | \"ready\" | \"error\";\n\nexport interface ModelStatus {\n state: ModelDownloadState;\n /** 0..1 — present only while downloading and Content-Length is known. */\n progress?: number;\n /** Present only in the \"error\" state. */\n error?: string;\n}\n\n// Abort a download if no bytes arrive for this long (stalled connection).\nconst DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;\n\nexport interface ModelDownloader {\n getStatus: (name: WhisperModelName) => ModelStatus;\n ensure: (name: WhisperModelName) => Promise<void>;\n}\n\nexport function createModelDownloader(modelsDir: string, logger: WhisperLogger = NOOP_LOGGER): ModelDownloader {\n // A finished file on disk is the source of truth for \"ready\"; this map tracks\n // transient progress + the last error.\n const downloadStatus = new Map<WhisperModelName, ModelStatus>();\n\n function getStatus(name: WhisperModelName): ModelStatus {\n const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\n }\n\n async function streamToFile(\n body: ReadableStream<Uint8Array>,\n partialPath: string,\n total: number,\n name: WhisperModelName,\n onProgress: () => void,\n ): Promise<void> {\n const reader = body.getReader();\n const fileStream = createWriteStream(partialPath);\n let received = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n onProgress();\n received += value.byteLength;\n if (!fileStream.write(value)) await once(fileStream, \"drain\");\n if (total > 0) downloadStatus.set(name, { state: \"downloading\", progress: received / total });\n }\n fileStream.end();\n await once(fileStream, \"finish\");\n } catch (err) {\n fileStream.destroy();\n throw err;\n }\n }\n\n async function download(name: WhisperModelName): Promise<void> {\n const spec = WHISPER_MODELS[name];\n mkdirSync(modelsDir, { recursive: true });\n const dest = modelFilePath(modelsDir, name);\n const partial = `${dest}.partial`;\n const controller = new AbortController();\n let stallTimer: ReturnType<typeof setTimeout> | null = null;\n const resetStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);\n };\n try {\n const response = await fetch(spec.url, { signal: controller.signal });\n if (!response.ok || !response.body) {\n throw new Error(`download failed: HTTP ${response.status}`);\n }\n const total = Number(response.headers.get(\"content-length\")) || 0;\n resetStall();\n await streamToFile(response.body, partial, total, name, resetStall);\n if (statSync(partial).size < spec.minBytes) {\n unlinkSync(partial);\n throw new Error(\"downloaded file is smaller than expected — likely truncated\");\n }\n renameSync(partial, dest);\n } finally {\n if (stallTimer) clearTimeout(stallTimer);\n }\n }\n\n // Fire-and-forget friendly: errors land in the status map, never thrown.\n // Idempotent — a second call while a download is in flight does nothing.\n async function ensure(name: WhisperModelName): Promise<void> {\n if (isModelReady(modelsDir, name)) {\n downloadStatus.set(name, { state: \"ready\" });\n return;\n }\n if (downloadStatus.get(name)?.state === \"downloading\") return;\n downloadStatus.set(name, { state: \"downloading\", progress: 0 });\n logger.info(\"model download: start\", { model: name });\n try {\n await download(name);\n downloadStatus.set(name, { state: \"ready\" });\n logger.info(\"model download: ok\", { model: name });\n } catch (err) {\n const error = errorMessage(err);\n downloadStatus.set(name, { state: \"error\", error });\n logger.error(\"model download: failed\", { model: name, error });\n }\n }\n\n return { getStatus, ensure };\n}\n","// whisper.cpp warm-model sidecar. Spawns `whisper-server` once with the model\n// preloaded and reuses it across transcriptions over its local HTTP API, so the\n// weights stay resident (no per-request reload). State is encapsulated per\n// instance via the factory closure.\n\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { createServer } from \"node:net\";\nimport { readFile } from \"node:fs/promises\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, ONE_SECOND_MS, type WhisperLogger } from \"./internal.ts\";\nimport { modelFilePath, type WhisperModelName } from \"./models.ts\";\n\nconst HOST = \"127.0.0.1\";\nconst READY_TIMEOUT_MS = 60 * ONE_SECOND_MS;\nconst READY_POLL_INTERVAL_MS = 500;\nconst INFERENCE_TIMEOUT_MS = 2 * ONE_MINUTE_MS;\n\ninterface ActiveSidecar {\n readonly port: number;\n readonly proc: ChildProcess;\n readonly model: WhisperModelName;\n}\n\nexport interface Sidecar {\n transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;\n warmup: (model: WhisperModelName) => Promise<void>;\n shutdown: () => void;\n}\n\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = createServer();\n srv.on(\"error\", reject);\n srv.listen(0, HOST, () => {\n const addr = srv.address();\n if (addr && typeof addr === \"object\") {\n const { port } = addr;\n srv.close(() => resolve(port));\n } else {\n srv.close(() => reject(new Error(\"could not determine a free port\")));\n }\n });\n });\n}\n\n/** Resolve once the server answers any HTTP request (any status = listener up),\n * or throw after the ready timeout. */\nasync function waitUntilReady(port: number): Promise<void> {\n const deadline = Date.now() + READY_TIMEOUT_MS;\n while (Date.now() < deadline) {\n try {\n await fetch(`http://${HOST}:${port}/`, { signal: AbortSignal.timeout(ONE_SECOND_MS) });\n return;\n } catch {\n await delay(READY_POLL_INTERVAL_MS);\n }\n }\n throw new Error(\"whisper-server did not become ready in time\");\n}\n\n// whisper-server logs verbosely to stderr; left unread the OS pipe buffer fills\n// and the child blocks on its next write. Drain into a small tail buffer.\nfunction drainStderr(proc: ChildProcess, tail: { text: string }): void {\n proc.stderr?.setEncoding(\"utf8\");\n proc.stderr?.on(\"data\", (chunk: string) => {\n tail.text = (tail.text + chunk).slice(-4000);\n });\n}\n\n// Resolve when the server answers, or reject on spawn failure (e.g. ENOENT) /\n// early exit. Listeners are one-shot and removed once the race settles.\nfunction waitForReadyOrFailure(proc: ChildProcess, port: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let onError: (err: Error) => void = () => undefined;\n let onExit: (code: number | null) => void = () => undefined;\n const cleanup = () => {\n proc.removeListener(\"error\", onError);\n proc.removeListener(\"exit\", onExit);\n };\n onError = (err: Error) => {\n cleanup();\n reject(new Error(`spawn failed: ${errorMessage(err)}`));\n };\n onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`exited early (code ${code})`));\n };\n proc.once(\"error\", onError);\n proc.once(\"exit\", onExit);\n waitUntilReady(port)\n .then(() => {\n cleanup();\n resolve();\n })\n .catch((err: unknown) => {\n cleanup();\n reject(err instanceof Error ? err : new Error(String(err)));\n });\n });\n}\n\nfunction parseInferenceText(data: unknown): string {\n if (typeof data === \"object\" && data !== null && \"text\" in data) {\n const { text } = data as { text: unknown };\n if (typeof text === \"string\") return text;\n }\n return \"\";\n}\n\nexport function createSidecar(modelsDir: string, serverBinary = \"whisper-server\", logger: WhisperLogger = NOOP_LOGGER): Sidecar {\n let sidecar: ActiveSidecar | null = null;\n let starting: { model: WhisperModelName; promise: Promise<ActiveSidecar> } | null = null;\n // The child of an in-flight start (before it's published as `sidecar`), plus a\n // token that `shutdown()` bumps to cancel a start that's still booting — so\n // shutdown can't return with a child that then publishes itself afterwards.\n let startingProc: ChildProcess | null = null;\n let startToken = 0;\n\n function shutdown(): void {\n startToken += 1;\n if (startingProc) {\n startingProc.kill();\n startingProc = null;\n }\n if (sidecar) {\n sidecar.proc.kill();\n sidecar = null;\n }\n }\n\n async function startSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n const token = ++startToken;\n const port = await findFreePort();\n const args = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(port)];\n logger.info(\"sidecar: spawning\", { model, port });\n const proc = spawn(serverBinary, args, { stdio: [\"ignore\", \"ignore\", \"pipe\"] });\n startingProc = proc;\n const stderrTail = { text: \"\" };\n drainStderr(proc, stderrTail);\n // Permanent error listener — a missing one would let a process 'error'\n // (e.g. ENOENT) throw uncaught and crash the host.\n proc.on(\"error\", (err) => logger.warn(\"sidecar: process error\", { model, error: errorMessage(err) }));\n proc.on(\"exit\", (code) => {\n logger.warn(\"sidecar: exited\", { model, code, stderrTail: stderrTail.text.slice(-500) });\n if (sidecar?.proc === proc) sidecar = null;\n });\n try {\n await waitForReadyOrFailure(proc, port);\n } catch (err) {\n proc.kill();\n throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);\n } finally {\n if (startingProc === proc) startingProc = null;\n }\n // shutdown() (or a newer start) ran while we were booting — discard this\n // child instead of publishing a sidecar after shutdown returned.\n // eslint-disable-next-line security/detect-possible-timing-attacks -- in-memory start-cancellation token, not an auth compare\n if (token !== startToken) {\n proc.kill();\n throw new Error(\"whisper-server start cancelled\");\n }\n sidecar = { port, proc, model };\n logger.info(\"sidecar: ready\", { model, port });\n return sidecar;\n }\n\n // Module-scoped spawn so it isn't a loop closure (no-loop-func). Only ever one\n // start is in flight at a time, so clearing `starting` on settle is safe.\n function beginStart(model: WhisperModelName): Promise<ActiveSidecar> {\n const promise = startSidecar(model).finally(() => {\n starting = null;\n });\n starting = { model, promise };\n return promise;\n }\n\n async function ensureSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n // Loop so that after awaiting an in-flight start for a DIFFERENT model we\n // re-evaluate; the decision-to-spawn path (beginStart) has no await, so the\n // first waiter sets `starting` synchronously and siblings then reuse it.\n for (;;) {\n if (sidecar && sidecar.model === model && !sidecar.proc.killed) return sidecar;\n if (starting && starting.model === model) return starting.promise;\n if (starting) {\n await starting.promise.catch(() => undefined);\n continue;\n }\n if (sidecar && sidecar.model !== model) shutdown();\n return beginStart(model);\n }\n }\n\n async function warmup(model: WhisperModelName): Promise<void> {\n try {\n await ensureSidecar(model);\n } catch (err) {\n logger.warn(\"sidecar: warmup failed\", { model, error: errorMessage(err) });\n }\n }\n\n async function transcribeWav(wavPath: string, language: string, model: WhisperModelName): Promise<string> {\n const active = await ensureSidecar(model);\n const buf = await readFile(wavPath);\n const form = new FormData();\n form.append(\"file\", new Blob([buf], { type: \"audio/wav\" }), \"audio.wav\");\n form.append(\"response_format\", \"json\");\n form.append(\"language\", language || \"auto\");\n let res: Response;\n try {\n res = await fetch(`http://${HOST}:${active.port}/inference`, { method: \"POST\", body: form, signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS) });\n } catch (err) {\n throw new Error(`whisper-server request failed: ${errorMessage(err)}`);\n }\n if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);\n return parseInferenceText(await res.json());\n }\n\n return { transcribeWav, warmup, shutdown };\n}\n","// Public server-side façade: wires the model downloader, the warm sidecar, and\n// ffmpeg conversion into one host-agnostic service. The host injects the models\n// directory + a logger and gates capability itself (platform / binary presence);\n// this package assumes the binaries exist when called.\n\nimport { mkdirSync } from \"node:fs\";\nimport { rm, writeFile } from \"node:fs/promises\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { NOOP_LOGGER, type WhisperLogger } from \"./internal.ts\";\nimport { convertToWav16k } from \"./ffmpeg.ts\";\nimport { createModelDownloader } from \"./models.ts\";\nimport { createSidecar } from \"./sidecar.ts\";\nimport { isModelReady, type ModelStatus, type WhisperModelName } from \"./models.ts\";\n\nexport interface WhisperOptions {\n /** Directory that holds the GGML model files (e.g. `{workspace}/models`). */\n modelsDir: string;\n logger?: WhisperLogger;\n /** Defaults to \"whisper-server\" / \"ffmpeg\" on PATH. */\n serverBinary?: string;\n ffmpegBinary?: string;\n}\n\nexport interface TranscribeRequest {\n base64: string;\n mimeType: string;\n language: string;\n model: WhisperModelName;\n}\n\nexport interface Whisper {\n isModelReady: (model: WhisperModelName) => boolean;\n getModelStatus: (model: WhisperModelName) => ModelStatus;\n /** Fire-and-forget friendly; never throws (errors land in the status). */\n ensureModelDownloaded: (model: WhisperModelName) => Promise<void>;\n warmup: (model: WhisperModelName) => Promise<void>;\n transcribe: (req: TranscribeRequest) => Promise<{ text: string }>;\n shutdown: () => void;\n}\n\n// whisper.cpp returns these sentinels for non-speech windows; treat them as\n// empty so the UI shows \"didn't catch that\" rather than a literal marker.\nconst BLANK_MARKERS = new Set([\"[blank_audio]\", \"[silence]\", \"(silence)\", \"[ inaudible ]\"]);\n\nfunction normalizeTranscript(raw: string): string {\n const trimmed = raw.trim().replace(/\\s+/g, \" \");\n return BLANK_MARKERS.has(trimmed.toLowerCase()) ? \"\" : trimmed;\n}\n\nexport function createWhisper(opts: WhisperOptions): Whisper {\n const { modelsDir } = opts;\n const logger = opts.logger ?? NOOP_LOGGER;\n const ffmpegBinary = opts.ffmpegBinary ?? \"ffmpeg\";\n const downloader = createModelDownloader(modelsDir, logger);\n const sidecar = createSidecar(modelsDir, opts.serverBinary ?? \"whisper-server\", logger);\n\n // Scratch dir for transient audio — a hidden subdir of the models dir so it\n // shares the (non-git) models tree. Files are deleted after each transcription.\n const scratchDir = path.join(modelsDir, \".scratch\");\n\n async function transcribe(req: TranscribeRequest): Promise<{ text: string }> {\n mkdirSync(scratchDir, { recursive: true });\n const clipId = randomUUID();\n const inputPath = path.join(scratchDir, `utterance-${clipId}.webm`);\n const wavPath = path.join(scratchDir, `utterance-${clipId}.wav`);\n try {\n await writeFile(inputPath, Buffer.from(req.base64, \"base64\"));\n await convertToWav16k(inputPath, wavPath, ffmpegBinary);\n const text = await sidecar.transcribeWav(wavPath, req.language, req.model);\n return { text: normalizeTranscript(text) };\n } finally {\n await rm(inputPath, { force: true });\n await rm(wavPath, { force: true });\n }\n }\n\n return {\n isModelReady: (model) => isModelReady(modelsDir, model),\n getModelStatus: (model) => downloader.getStatus(model),\n ensureModelDownloaded: (model) => downloader.ensure(model),\n warmup: (model) => sidecar.warmup(model),\n transcribe,\n shutdown: () => sidecar.shutdown(),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.ts","../../src/whisper/sidecar.ts","../../src/whisper/whisper.ts"],"sourcesContent":["// Small self-contained utilities so the package has no host dependencies.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Minimal logger the host can inject; defaults to no-op so the package\n * is silent unless wired up. */\nexport interface WhisperLogger {\n info: (message: string, data?: unknown) => void;\n warn: (message: string, data?: unknown) => void;\n error: (message: string, data?: unknown) => void;\n}\n\nconst NOOP = (): void => undefined;\nexport const NOOP_LOGGER: WhisperLogger = { info: NOOP, warn: NOOP, error: NOOP };\n","// Thin wrapper around the system `ffmpeg` binary (provided by the host) for\n// converting a browser webm/opus clip to the 16 kHz mono 16-bit WAV whisper.cpp\n// requires.\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { ONE_MINUTE_MS } from \"./internal.ts\";\n\nconst execFileAsync = promisify(execFile);\n\n/** ffmpeg args to decode any input to 16 kHz mono signed-16-bit WAV. Pure +\n * exported for unit tests. */\nexport function buildWav16kArgs(inputPath: string, outputPath: string): string[] {\n return [\"-y\", \"-loglevel\", \"error\", \"-i\", inputPath, \"-ar\", \"16000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", outputPath];\n}\n\n/** Convert `inputPath` to a 16 kHz mono WAV at `outputPath`. Throws on ffmpeg\n * failure or timeout. */\nexport async function convertToWav16k(inputPath: string, outputPath: string, ffmpegBinary = \"ffmpeg\"): Promise<void> {\n await execFileAsync(ffmpegBinary, buildWav16kArgs(inputPath, outputPath), {\n timeout: ONE_MINUTE_MS,\n });\n}\n","// Whisper GGML model registry + on-disk management. The host injects the models\n// directory (e.g. `{workspace}/models`); nothing here reads a host module.\n\nimport { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { once } from \"node:events\";\nimport path from \"node:path\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, type WhisperLogger } from \"./internal.ts\";\n\nexport interface WhisperModelSpec {\n /** GGML filename — identical on disk and in the Hugging Face repo. */\n readonly file: string;\n /** Download URL (Hugging Face whisper.cpp model repo). */\n readonly url: string;\n /** Conservative lower bound on the finished file size, in bytes — guards\n * against a truncated transfer or an HTML error page saved as the model\n * without pinning an exact checksum. */\n readonly minBytes: number;\n}\n\nconst HF_BASE = \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main\";\n\n// large-v3-turbo: strong accuracy, near-real-time on Apple Silicon with Metal.\n// small/base are lighter fallbacks for low-RAM machines.\nexport const WHISPER_MODELS = {\n \"large-v3-turbo\": { file: \"ggml-large-v3-turbo.bin\", url: `${HF_BASE}/ggml-large-v3-turbo.bin`, minBytes: 1_000_000_000 },\n small: { file: \"ggml-small.bin\", url: `${HF_BASE}/ggml-small.bin`, minBytes: 300_000_000 },\n base: { file: \"ggml-base.bin\", url: `${HF_BASE}/ggml-base.bin`, minBytes: 100_000_000 },\n} as const satisfies Record<string, WhisperModelSpec>;\n\nexport type WhisperModelName = keyof typeof WHISPER_MODELS;\nexport const DEFAULT_WHISPER_MODEL: WhisperModelName = \"large-v3-turbo\";\n\nexport function isWhisperModelName(value: unknown): value is WhisperModelName {\n // Own-property check — `in` would accept inherited keys like \"toString\",\n // which then crash the `WHISPER_MODELS[name]` lookups instead of falling\n // back to the default.\n return typeof value === \"string\" && Object.prototype.hasOwnProperty.call(WHISPER_MODELS, value);\n}\n\n/** Resolve a possibly-unset / unknown model name to a valid one. */\nexport function resolveModelName(name: string | undefined): WhisperModelName {\n return isWhisperModelName(name) ? name : DEFAULT_WHISPER_MODEL;\n}\n\nexport function modelFilePath(modelsDir: string, name: WhisperModelName): string {\n return path.join(modelsDir, WHISPER_MODELS[name].file);\n}\n\n/** A model is \"ready\" when its file exists and meets the size floor. */\nexport function isModelReady(modelsDir: string, name: WhisperModelName): boolean {\n try {\n return statSync(modelFilePath(modelsDir, name)).size >= WHISPER_MODELS[name].minBytes;\n } catch {\n return false;\n }\n}\n\nexport type ModelDownloadState = \"idle\" | \"downloading\" | \"ready\" | \"error\";\n\nexport interface ModelStatus {\n state: ModelDownloadState;\n /** 0..1 — present only while downloading and Content-Length is known. */\n progress?: number;\n /** Present only in the \"error\" state. */\n error?: string;\n}\n\n// Abort a download if no bytes arrive for this long (stalled connection).\nconst DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;\n\nexport interface ModelDownloader {\n getStatus: (name: WhisperModelName) => ModelStatus;\n ensure: (name: WhisperModelName) => Promise<void>;\n}\n\nexport function createModelDownloader(modelsDir: string, logger: WhisperLogger = NOOP_LOGGER): ModelDownloader {\n // A finished file on disk is the source of truth for \"ready\"; this map tracks\n // transient progress + the last error.\n const downloadStatus = new Map<WhisperModelName, ModelStatus>();\n\n function getStatus(name: WhisperModelName): ModelStatus {\n const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\n }\n\n async function streamToFile(\n body: ReadableStream<Uint8Array>,\n partialPath: string,\n total: number,\n name: WhisperModelName,\n onProgress: () => void,\n ): Promise<void> {\n const reader = body.getReader();\n const fileStream = createWriteStream(partialPath);\n let received = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n onProgress();\n received += value.byteLength;\n if (!fileStream.write(value)) await once(fileStream, \"drain\");\n if (total > 0) downloadStatus.set(name, { state: \"downloading\", progress: received / total });\n }\n fileStream.end();\n await once(fileStream, \"finish\");\n } catch (err) {\n fileStream.destroy();\n throw err;\n }\n }\n\n async function download(name: WhisperModelName): Promise<void> {\n const spec = WHISPER_MODELS[name];\n mkdirSync(modelsDir, { recursive: true });\n const dest = modelFilePath(modelsDir, name);\n const partial = `${dest}.partial`;\n const controller = new AbortController();\n let stallTimer: ReturnType<typeof setTimeout> | null = null;\n const resetStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);\n };\n try {\n const response = await fetch(spec.url, { signal: controller.signal });\n if (!response.ok || !response.body) {\n throw new Error(`download failed: HTTP ${response.status}`);\n }\n const total = Number(response.headers.get(\"content-length\")) || 0;\n resetStall();\n await streamToFile(response.body, partial, total, name, resetStall);\n if (statSync(partial).size < spec.minBytes) {\n unlinkSync(partial);\n throw new Error(\"downloaded file is smaller than expected — likely truncated\");\n }\n renameSync(partial, dest);\n } finally {\n if (stallTimer) clearTimeout(stallTimer);\n }\n }\n\n // Fire-and-forget friendly: errors land in the status map, never thrown.\n // Idempotent — a second call while a download is in flight does nothing.\n async function ensure(name: WhisperModelName): Promise<void> {\n if (isModelReady(modelsDir, name)) {\n downloadStatus.set(name, { state: \"ready\" });\n return;\n }\n if (downloadStatus.get(name)?.state === \"downloading\") return;\n downloadStatus.set(name, { state: \"downloading\", progress: 0 });\n logger.info(\"model download: start\", { model: name });\n try {\n await download(name);\n downloadStatus.set(name, { state: \"ready\" });\n logger.info(\"model download: ok\", { model: name });\n } catch (err) {\n const error = errorMessage(err);\n downloadStatus.set(name, { state: \"error\", error });\n logger.error(\"model download: failed\", { model: name, error });\n }\n }\n\n return { getStatus, ensure };\n}\n","// whisper.cpp warm-model sidecar. Spawns `whisper-server` once with the model\n// preloaded and reuses it across transcriptions over its local HTTP API, so the\n// weights stay resident (no per-request reload). State is encapsulated per\n// instance via the factory closure.\n\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { createServer } from \"node:net\";\nimport { readFile } from \"node:fs/promises\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, ONE_SECOND_MS, type WhisperLogger } from \"./internal.ts\";\nimport { modelFilePath, type WhisperModelName } from \"./models.ts\";\n\nconst HOST = \"127.0.0.1\";\nconst READY_TIMEOUT_MS = 60 * ONE_SECOND_MS;\nconst READY_POLL_INTERVAL_MS = 500;\nconst INFERENCE_TIMEOUT_MS = 2 * ONE_MINUTE_MS;\n\ninterface ActiveSidecar {\n readonly port: number;\n readonly proc: ChildProcess;\n readonly model: WhisperModelName;\n}\n\nexport interface Sidecar {\n transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;\n warmup: (model: WhisperModelName) => Promise<void>;\n shutdown: () => void;\n}\n\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = createServer();\n srv.on(\"error\", reject);\n srv.listen(0, HOST, () => {\n const addr = srv.address();\n if (addr && typeof addr === \"object\") {\n const { port } = addr;\n srv.close(() => resolve(port));\n } else {\n srv.close(() => reject(new Error(\"could not determine a free port\")));\n }\n });\n });\n}\n\n/** Resolve once the server answers any HTTP request (any status = listener up),\n * or throw after the ready timeout. */\nasync function waitUntilReady(port: number): Promise<void> {\n const deadline = Date.now() + READY_TIMEOUT_MS;\n while (Date.now() < deadline) {\n try {\n await fetch(`http://${HOST}:${port}/`, { signal: AbortSignal.timeout(ONE_SECOND_MS) });\n return;\n } catch {\n await delay(READY_POLL_INTERVAL_MS);\n }\n }\n throw new Error(\"whisper-server did not become ready in time\");\n}\n\n// whisper-server logs verbosely to stderr; left unread the OS pipe buffer fills\n// and the child blocks on its next write. Drain into a small tail buffer.\nfunction drainStderr(proc: ChildProcess, tail: { text: string }): void {\n proc.stderr?.setEncoding(\"utf8\");\n proc.stderr?.on(\"data\", (chunk: string) => {\n tail.text = (tail.text + chunk).slice(-4000);\n });\n}\n\n// Resolve when the server answers, or reject on spawn failure (e.g. ENOENT) /\n// early exit. Listeners are one-shot and removed once the race settles.\nfunction waitForReadyOrFailure(proc: ChildProcess, port: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let onError: (err: Error) => void = () => undefined;\n let onExit: (code: number | null) => void = () => undefined;\n const cleanup = () => {\n proc.removeListener(\"error\", onError);\n proc.removeListener(\"exit\", onExit);\n };\n onError = (err: Error) => {\n cleanup();\n reject(new Error(`spawn failed: ${errorMessage(err)}`));\n };\n onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`exited early (code ${code})`));\n };\n proc.once(\"error\", onError);\n proc.once(\"exit\", onExit);\n waitUntilReady(port)\n .then(() => {\n cleanup();\n resolve();\n })\n .catch((err: unknown) => {\n cleanup();\n reject(err instanceof Error ? err : new Error(String(err)));\n });\n });\n}\n\nfunction parseInferenceText(data: unknown): string {\n if (typeof data === \"object\" && data !== null && \"text\" in data) {\n const { text } = data as { text: unknown };\n if (typeof text === \"string\") return text;\n }\n return \"\";\n}\n\nexport function createSidecar(modelsDir: string, serverBinary = \"whisper-server\", logger: WhisperLogger = NOOP_LOGGER): Sidecar {\n let sidecar: ActiveSidecar | null = null;\n let starting: { model: WhisperModelName; promise: Promise<ActiveSidecar> } | null = null;\n // The child of an in-flight start (before it's published as `sidecar`), plus a\n // token that `shutdown()` bumps to cancel a start that's still booting — so\n // shutdown can't return with a child that then publishes itself afterwards.\n let startingProc: ChildProcess | null = null;\n let startToken = 0;\n\n function shutdown(): void {\n startToken += 1;\n if (startingProc) {\n startingProc.kill();\n startingProc = null;\n }\n if (sidecar) {\n sidecar.proc.kill();\n sidecar = null;\n }\n }\n\n async function startSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n const token = ++startToken;\n const port = await findFreePort();\n const args = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(port)];\n logger.info(\"sidecar: spawning\", { model, port });\n const proc = spawn(serverBinary, args, { stdio: [\"ignore\", \"ignore\", \"pipe\"] });\n startingProc = proc;\n const stderrTail = { text: \"\" };\n drainStderr(proc, stderrTail);\n // Permanent error listener — a missing one would let a process 'error'\n // (e.g. ENOENT) throw uncaught and crash the host.\n proc.on(\"error\", (err) => logger.warn(\"sidecar: process error\", { model, error: errorMessage(err) }));\n proc.on(\"exit\", (code) => {\n logger.warn(\"sidecar: exited\", { model, code, stderrTail: stderrTail.text.slice(-500) });\n if (sidecar?.proc === proc) sidecar = null;\n });\n try {\n await waitForReadyOrFailure(proc, port);\n } catch (err) {\n proc.kill();\n throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);\n } finally {\n if (startingProc === proc) startingProc = null;\n }\n // shutdown() (or a newer start) ran while we were booting — discard this\n // child instead of publishing a sidecar after shutdown returned.\n // eslint-disable-next-line security/detect-possible-timing-attacks -- in-memory start-cancellation token, not an auth compare\n if (token !== startToken) {\n proc.kill();\n throw new Error(\"whisper-server start cancelled\");\n }\n sidecar = { port, proc, model };\n logger.info(\"sidecar: ready\", { model, port });\n return sidecar;\n }\n\n // Module-scoped spawn so it isn't a loop closure (no-loop-func). Only ever one\n // start is in flight at a time, so clearing `starting` on settle is safe.\n function beginStart(model: WhisperModelName): Promise<ActiveSidecar> {\n const promise = startSidecar(model).finally(() => {\n starting = null;\n });\n starting = { model, promise };\n return promise;\n }\n\n async function ensureSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n // Loop so that after awaiting an in-flight start for a DIFFERENT model we\n // re-evaluate; the decision-to-spawn path (beginStart) has no await, so the\n // first waiter sets `starting` synchronously and siblings then reuse it.\n for (;;) {\n if (sidecar && sidecar.model === model && !sidecar.proc.killed) return sidecar;\n if (starting && starting.model === model) return starting.promise;\n if (starting) {\n await starting.promise.catch(() => undefined);\n continue;\n }\n if (sidecar && sidecar.model !== model) shutdown();\n return beginStart(model);\n }\n }\n\n async function warmup(model: WhisperModelName): Promise<void> {\n try {\n await ensureSidecar(model);\n } catch (err) {\n logger.warn(\"sidecar: warmup failed\", { model, error: errorMessage(err) });\n }\n }\n\n async function transcribeWav(wavPath: string, language: string, model: WhisperModelName): Promise<string> {\n const active = await ensureSidecar(model);\n const buf = await readFile(wavPath);\n const form = new FormData();\n form.append(\"file\", new Blob([buf], { type: \"audio/wav\" }), \"audio.wav\");\n form.append(\"response_format\", \"json\");\n form.append(\"language\", language || \"auto\");\n let res: Response;\n try {\n res = await fetch(`http://${HOST}:${active.port}/inference`, { method: \"POST\", body: form, signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS) });\n } catch (err) {\n throw new Error(`whisper-server request failed: ${errorMessage(err)}`);\n }\n if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);\n return parseInferenceText(await res.json());\n }\n\n return { transcribeWav, warmup, shutdown };\n}\n","// Public server-side façade: wires the model downloader, the warm sidecar, and\n// ffmpeg conversion into one host-agnostic service. The host injects the models\n// directory + a logger and gates capability itself (platform / binary presence);\n// this package assumes the binaries exist when called.\n\nimport { mkdirSync } from \"node:fs\";\nimport { rm, writeFile } from \"node:fs/promises\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { NOOP_LOGGER, type WhisperLogger } from \"./internal.ts\";\nimport { convertToWav16k } from \"./ffmpeg.ts\";\nimport { createModelDownloader } from \"./models.ts\";\nimport { createSidecar } from \"./sidecar.ts\";\nimport { isModelReady, type ModelStatus, type WhisperModelName } from \"./models.ts\";\n\nexport interface WhisperOptions {\n /** Directory that holds the GGML model files (e.g. `{workspace}/models`). */\n modelsDir: string;\n logger?: WhisperLogger;\n /** Defaults to \"whisper-server\" / \"ffmpeg\" on PATH. */\n serverBinary?: string;\n ffmpegBinary?: string;\n}\n\nexport interface TranscribeRequest {\n base64: string;\n mimeType: string;\n language: string;\n model: WhisperModelName;\n}\n\nexport interface Whisper {\n isModelReady: (model: WhisperModelName) => boolean;\n getModelStatus: (model: WhisperModelName) => ModelStatus;\n /** Fire-and-forget friendly; never throws (errors land in the status). */\n ensureModelDownloaded: (model: WhisperModelName) => Promise<void>;\n warmup: (model: WhisperModelName) => Promise<void>;\n transcribe: (req: TranscribeRequest) => Promise<{ text: string }>;\n shutdown: () => void;\n}\n\n// whisper.cpp returns these sentinels for non-speech windows; treat them as\n// empty so the UI shows \"didn't catch that\" rather than a literal marker.\nconst BLANK_MARKERS = new Set([\"[blank_audio]\", \"[silence]\", \"(silence)\", \"[ inaudible ]\"]);\n\nfunction normalizeTranscript(raw: string): string {\n const trimmed = raw.trim().replace(/\\s+/g, \" \");\n return BLANK_MARKERS.has(trimmed.toLowerCase()) ? \"\" : trimmed;\n}\n\nexport function createWhisper(opts: WhisperOptions): Whisper {\n const { modelsDir } = opts;\n const logger = opts.logger ?? NOOP_LOGGER;\n const ffmpegBinary = opts.ffmpegBinary ?? \"ffmpeg\";\n const downloader = createModelDownloader(modelsDir, logger);\n const sidecar = createSidecar(modelsDir, opts.serverBinary ?? \"whisper-server\", logger);\n\n // Scratch dir for transient audio — a hidden subdir of the models dir so it\n // shares the (non-git) models tree. Files are deleted after each transcription.\n const scratchDir = path.join(modelsDir, \".scratch\");\n\n async function transcribe(req: TranscribeRequest): Promise<{ text: string }> {\n mkdirSync(scratchDir, { recursive: true });\n const clipId = randomUUID();\n const inputPath = path.join(scratchDir, `utterance-${clipId}.webm`);\n const wavPath = path.join(scratchDir, `utterance-${clipId}.wav`);\n try {\n await writeFile(inputPath, Buffer.from(req.base64, \"base64\"));\n await convertToWav16k(inputPath, wavPath, ffmpegBinary);\n const text = await sidecar.transcribeWav(wavPath, req.language, req.model);\n return { text: normalizeTranscript(text) };\n } finally {\n await rm(inputPath, { force: true });\n await rm(wavPath, { force: true });\n }\n }\n\n return {\n isModelReady: (model) => isModelReady(modelsDir, model),\n getModelStatus: (model) => downloader.getStatus(model),\n ensureModelDownloaded: (model) => downloader.ensure(model),\n warmup: (model) => sidecar.warmup(model),\n transcribe,\n shutdown: () => sidecar.shutdown(),\n };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts"],"sourcesContent":["// Preset skills bundled with mulmoclaude.\n//\n// History: introduced in #1210 to ship launcher-managed \"factory\"\n// skills like `mc-library`. Originally synced straight into\n// `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset\n// auto-active and inflated the Claude system prompt as new presets\n// landed.\n//\n// #1335 PR-A flipped the destination to the catalog\n// (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog\n// entries are visible to UI / tooling but NOT discovered by Claude\n// Code's slash-command resolver — they don't enter the system\n// prompt unless the user (or a later UI in PR-B) explicitly copies\n// one into `.claude/skills/`.\n//\n// The launcher overwrites catalog entries unconditionally on every\n// boot — they're factory defaults, not user state. Anything in\n// `.claude/skills/` (active layer) is left untouched.\n//\n// `syncPresetSkills(...)` is exported as a pure-ish helper (takes\n// paths + a logger sink, returns a summary) so tests can drive it\n// against tmpdirs without touching a real workspace.\n\nimport { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, type Dirent } from \"node:fs\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { PRESET_SLUG_PREFIX, isPresetSlug } from \"./slug.js\";\n\n// Inlined (was the host's ../utils/errors.js) so the package has no host coupling.\nfunction errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n// Recursively mirror `srcDir` into `destDir`. Used by the preset\n// sync so a preset skill that ships sibling assets (e.g.\n// `schema.json` for schema-driven apps, `templates/*.html`) gets\n// copied alongside `SKILL.md` rather than silently dropped. Only\n// regular files and directories are followed — symlinks / FIFOs /\n// sockets are skipped because the preset tree is launcher-managed\n// and shouldn't contain them.\nfunction copyDirTreeSync(srcDir: string, destDir: string): void {\n mkdirSync(destDir, { recursive: true });\n for (const entry of readdirSync(srcDir, { withFileTypes: true })) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n copyDirTreeSync(srcPath, destPath);\n } else if (entry.isFile()) {\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\nconst SKILL_FILENAME = \"SKILL.md\";\n\nexport interface SyncPresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Destination directory:\n * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog\n * half of the catalog-vs-active split — entries here are visible\n * to UI but NOT to Claude Code's prompt-time skill resolver. */\n destDir: string;\n /** Logger callbacks — kept injectable so tests don't need to\n * spin up the structured logger. The boot-side wrapper threads\n * these through to `log.info` / `log.warn`. */\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncPresetSkillsResult {\n /** Slugs successfully copied (or refreshed) from source to dest. */\n copied: string[];\n /** Slugs removed from dest because they no longer exist in source.\n * Bounded to `mc-*` entries — user-authored slugs are never\n * considered for removal. */\n removed: string[];\n /** Source entries that failed validation (wrong prefix, missing\n * SKILL.md, etc.) and were skipped. Each entry is human-readable. */\n skipped: string[];\n}\n\n// Classification of one source entry. `silent` distinguishes\n// structural skips (hidden files, non-directory entries — not the\n// dev's fault) from misconfigurations (bad slug, missing or\n// non-regular SKILL.md — the dev WANTS to know). The boolean lives\n// on the verdict so the caller never has to string-match `reason`,\n// which would silently drop warnings if reason wording changed\n// (CodeRabbit review).\ntype Verdict = { ok: true } | { ok: false; reason: string; silent: boolean };\n\nfunction classifySourceEntry(sourceDir: string, entry: string): Verdict {\n if (entry.startsWith(\".\")) return { ok: false, reason: \"hidden\", silent: true };\n const slugDir = path.join(sourceDir, entry);\n let dirInfo;\n try {\n dirInfo = statSync(slugDir);\n } catch {\n return { ok: false, reason: \"stat failed\", silent: true };\n }\n if (!dirInfo.isDirectory()) return { ok: false, reason: \"not a directory\", silent: true };\n if (!isPresetSlug(entry)) return { ok: false, reason: `slug must start with \"${PRESET_SLUG_PREFIX}\"`, silent: false };\n // Validate SKILL.md is a regular file — `existsSync` alone\n // accepts a directory at that path, which would then crash\n // copyFileSync. Codex review caught this edge case.\n const skillPath = path.join(slugDir, SKILL_FILENAME);\n let skillInfo;\n try {\n skillInfo = statSync(skillPath);\n } catch {\n return { ok: false, reason: `missing ${SKILL_FILENAME}`, silent: false };\n }\n if (!skillInfo.isFile()) return { ok: false, reason: `${SKILL_FILENAME} must be a regular file`, silent: false };\n return { ok: true };\n}\n\n/** Prepare the destination slug dir. Returns false if the slot is\n * occupied by a regular file (local corruption / hand edits) — the\n * caller logs + skips so one bad entry can't crash the whole boot\n * (Codex review iter-1). */\nfunction ensureDestSlugDir(destSlugDir: string): boolean {\n let info;\n try {\n info = statSync(destSlugDir);\n } catch {\n mkdirSync(destSlugDir, { recursive: true });\n return true;\n }\n return info.isDirectory();\n}\n\n/** Filesystem ops `_replaceSlugTree` depends on — injectable so the\n * mid-swap failure path can be regression-tested without real IO faults. */\nexport interface SlugTreeFsOps {\n copyTree: (src: string, dest: string) => void;\n rename: (from: string, dest: string) => void;\n remove: (target: string) => void;\n exists: (target: string) => boolean;\n}\n\nconst REAL_FS_OPS: SlugTreeFsOps = {\n copyTree: copyDirTreeSync,\n rename: renameSync,\n remove: (target) => rmSync(target, { recursive: true, force: true }),\n exists: existsSync,\n};\n\n/** Refresh one preset slot as a rollback-safe stage-and-swap. Wipe-and-replace\n * (not merge) so stale sibling assets — e.g. a schema.json dropped between\n * releases — don't linger; the catalog preset slot is launcher-owned, so user\n * edits there are not preserved across boots.\n *\n * The live slot is never left without a recoverable copy:\n * 1. Stage: copy the source into a temp sibling. A copy failure leaves the\n * existing preset untouched (temp dir removed).\n * 2. Move the existing tree ASIDE to a backup (rename, not delete), so the\n * old contents survive even if the next step fails.\n * 3. Move the staged copy into place. On failure, restore from the backup;\n * if even the restore fails, BOTH backup and staging are preserved for\n * manual recovery rather than deleted.\n * 4. On success, drop the backup.\n *\n * Exported with a `_` prefix only so the rename-failure-after-move path can be\n * regression-tested via the injected `fsOps`. Throws on failure (caller records\n * + skips). */\nexport function _replaceSlugTree(sourceSlugDir: string, destSlugDir: string, fsOps: SlugTreeFsOps = REAL_FS_OPS): void {\n const staging = `${destSlugDir}.tmp-${randomUUID()}`;\n try {\n fsOps.copyTree(sourceSlugDir, staging);\n } catch (err) {\n fsOps.remove(staging); // dest untouched\n throw err;\n }\n const backup = `${destSlugDir}.bak-${randomUUID()}`;\n let backedUp = false;\n if (fsOps.exists(destSlugDir)) {\n try {\n fsOps.rename(destSlugDir, backup);\n backedUp = true;\n } catch (err) {\n fsOps.remove(staging); // dest untouched; nothing was moved\n throw err;\n }\n }\n try {\n fsOps.rename(staging, destSlugDir);\n } catch (err) {\n if (backedUp) {\n try {\n fsOps.rename(backup, destSlugDir); // roll back to the previous tree\n fsOps.remove(staging);\n } catch {\n // Rollback failed — preserve BOTH backup and staging for manual\n // recovery rather than leaving the slot empty.\n }\n } else {\n fsOps.remove(staging); // dest never existed; nothing to restore\n }\n throw err;\n }\n if (backedUp) fsOps.remove(backup);\n}\n\nfunction copySourcesIntoDest(sourceDir: string, destDir: string, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): Set<string> {\n // `keep` is the set of slugs the retirement pass must NOT prune. It tracks\n // valid SOURCE slugs (present + usable dest slot), NOT successful copies — so\n // a slug whose refresh fails transiently keeps its existing contents (left\n // intact by the stage-and-swap above) instead of being pruned as \"retired\".\n const keep = new Set<string>();\n for (const entry of readdirSync(sourceDir)) {\n const verdict = classifySourceEntry(sourceDir, entry);\n if (!verdict.ok) {\n if (!verdict.silent) {\n result.skipped.push(`${entry}: ${verdict.reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason: verdict.reason });\n }\n continue;\n }\n const destSlugDir = path.join(destDir, entry);\n if (!ensureDestSlugDir(destSlugDir)) {\n const reason = \"destination slot occupied by a non-directory; skipping\";\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason, destSlugDir });\n continue;\n }\n // The slug exists in source with a usable dest slot — keep it regardless of\n // whether the refresh below succeeds, so a transient failure doesn't get it\n // pruned by `removeRetiredPresets`.\n keep.add(entry);\n // Per-slug isolation: a transient IO error / permission issue / partial\n // corruption on one preset must not abort syncing the rest, nor destroy\n // the existing copy. Skip the offender (recorded) and continue.\n try {\n _replaceSlugTree(path.join(sourceDir, entry), destSlugDir);\n result.copied.push(entry);\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset copy failed, skipping\", { slug: entry, reason });\n }\n }\n return keep;\n}\n\nfunction removeRetiredPresets(destDir: string, keep: ReadonlySet<string>, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): void {\n for (const entry of readdirSync(destDir)) {\n if (!isPresetSlug(entry)) continue;\n if (keep.has(entry)) continue;\n const stalePath = path.join(destDir, entry);\n try {\n if (!statSync(stalePath).isDirectory()) continue;\n } catch {\n continue;\n }\n rmSync(stalePath, { recursive: true, force: true });\n result.removed.push(entry);\n opts.onInfo?.(\"removed retired preset skill\", { slug: entry });\n }\n}\n\n/** Copy every preset slug from `sourceDir` into `destDir` (the\n * preset slot under the catalog root), then remove any `mc-*`\n * entries in `destDir` that no longer have a source. The catalog\n * preset subdir is fully launcher-owned, so the `mc-*` prefix\n * check at destination is defence-in-depth: a stray non-preset\n * slug landing in `catalog/preset/` is unexpected, and we'd\n * rather skip it than silently delete a directory we don't\n * recognise. */\nexport function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {\n const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };\n if (!existsSync(opts.sourceDir)) {\n // No preset directory in the launcher tarball — nothing to do.\n // This is the legitimate \"no presets shipped yet\" state.\n return result;\n }\n // Source-side validation: the launcher's preset path COULD exist\n // as a regular file (a packaging bug, a corrupted install). Without\n // this guard, `readdirSync(sourceDir)` would throw ENOTDIR and\n // crash boot. Codex review iter-3.\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n const reason = `source path stat failed: ${errorMessage(err)}`;\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n if (!sourceInfo.isDirectory()) {\n const reason = \"source path exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n // The root dest itself can be corrupted into a regular file by a\n // user / external tool; mkdirSync would throw EEXIST and crash\n // boot. Treat it as a recoverable \"skip the entire sync\" state\n // — log a clear warning so the user sees what to fix.\n // (Codex review iter-2.)\n if (!ensureDestSlugDir(opts.destDir)) {\n const reason = \"root dest exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.destDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { destDir: opts.destDir, reason });\n return result;\n }\n const keep = copySourcesIntoDest(opts.sourceDir, opts.destDir, opts, result);\n removeRetiredPresets(opts.destDir, keep, opts, result);\n if (result.copied.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"preset skills synced\", {\n copied: result.copied.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------\n// Active-layer sync: keep starred mc-* presets in lockstep with\n// their launcher-bundled source.\n//\n// Motivation: the catalog sync above refreshes\n// `data/skills/catalog/preset/<slug>/` on every boot, but once a\n// user stars an entry the active copy in\n// `<workspace>/.claude/skills/<slug>/` is never updated even when\n// the launcher ships a new SKILL.md (e.g. a typo fix or, as the\n// trigger for this code, the schema-driven-apps → collections\n// rename). The SKILL.md front-matter explicitly says\n// \"do not edit this file in the workspace, it is overwritten on\n// every server boot\" — until this function existed, that was a\n// promise the active layer didn't keep.\n//\n// Safety model:\n// - Only `mc-*` slugs are touched (defensive prefix check —\n// never touches user-authored skills).\n// - Per-file diff: a file is overwritten only if its bytes\n// differ from the source. No-op when already up to date.\n// - User-added files inside an active slug dir are left alone\n// (we walk the source tree, not the dest tree).\n// - If a file IS overwritten, the previous contents are first\n// renamed to `<file>.bak.<timestamp>` so a user who had\n// locally tweaked the preset can recover.\n// - A slug whose active dir doesn't exist (= not starred yet)\n// is skipped entirely, never auto-starred.\n// ---------------------------------------------------------------\n\nexport interface SyncActivePresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Active skills directory: `<workspaceRoot>/.claude/skills/`. */\n activeDir: string;\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncActivePresetSkillsResult {\n /** Slugs whose active copy had at least one file overwritten. */\n updated: string[];\n /** Slugs whose active copy already matched the source — no-op. */\n unchanged: string[];\n /** Slugs that haven't been starred yet (no active dir present).\n * Listed for diagnostics; the function never auto-stars. */\n notActive: string[];\n /** Active `mc-*` slugs pruned because the launcher no longer ships a\n * source preset for them (a retired preset). Bounded to `mc-*` —\n * user-authored skills are never removed. */\n removed: string[];\n /** Per-slug failure messages (permission errors, etc.). */\n skipped: string[];\n /** Common timestamp suffix used for every backup file produced by\n * this run. Exposed so the boot-time log can point a user at the\n * exact glob to inspect. */\n backupSuffix: string | null;\n}\n\ntype FileSyncOutcome = \"updated\" | \"unchanged\" | \"skipped\";\n\nfunction filesEqual(left: string, right: string): boolean {\n try {\n return readFileSync(left).equals(readFileSync(right));\n } catch {\n return false;\n }\n}\n\n/** Copy `srcPath` over `destPath`. If `destPath` already exists and\n * differs, rename it to `destPath + backupExt` first so the user's\n * prior contents are recoverable. If `destPath` doesn't exist, just\n * copy (no backup needed). Pure: no logging here — caller decides. */\nfunction syncOneFile(srcPath: string, destPath: string, backupExt: string): FileSyncOutcome {\n let destExists: boolean;\n try {\n destExists = statSync(destPath).isFile();\n } catch {\n destExists = false;\n }\n if (!destExists) {\n try {\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n }\n if (filesEqual(srcPath, destPath)) return \"unchanged\";\n try {\n renameSync(destPath, destPath + backupExt);\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n}\n\ninterface DirSyncStats {\n updated: number;\n unchanged: number;\n skipped: number;\n}\n\nfunction syncDirTreeDiff(srcDir: string, destDir: string, backupExt: string): DirSyncStats {\n const stats: DirSyncStats = { updated: 0, unchanged: 0, skipped: 0 };\n try {\n mkdirSync(destDir, { recursive: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n let entries: Dirent[];\n try {\n entries = readdirSync(srcDir, { withFileTypes: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n for (const entry of entries) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n const sub = syncDirTreeDiff(srcPath, destPath, backupExt);\n stats.updated += sub.updated;\n stats.unchanged += sub.unchanged;\n stats.skipped += sub.skipped;\n } else if (entry.isFile()) {\n const outcome = syncOneFile(srcPath, destPath, backupExt);\n stats[outcome]++;\n }\n // Symlinks / sockets / FIFOs intentionally ignored (the launcher\n // preset tree shouldn't contain them).\n }\n return stats;\n}\n\nfunction abortActiveSync(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, reason: string): SyncActivePresetSkillsResult {\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"active preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n}\n\n/** True iff `absPath`'s realpath resolves inside `rootPath`'s\n * realpath. Defends `processActiveSlug` against a starred `mc-*`\n * slug that's actually a symlink to somewhere outside\n * `activeDir`: without this check, the recursive copy below would\n * follow the symlink and write through to the link's target\n * (potentially anywhere on disk). Returns false on any error so\n * the caller treats unreadable paths as \"refused\" rather than\n * \"OK to write\". */\nfunction isRealpathInside(absPath: string, rootPath: string): boolean {\n try {\n const real = realpathSync(absPath);\n const rootReal = realpathSync(rootPath);\n return real === rootReal || real.startsWith(rootReal + path.sep);\n } catch {\n return false;\n }\n}\n\ntype DestVerdict = { ok: true } | { ok: false; reason: string } | { kind: \"not-active\" };\n\n/** Validate the active dest slug dir before writing through it.\n * Returns:\n * - `{ ok: true }` — proceed with the sync\n * - `{ kind: \"not-active\" }` — slug hasn't been starred yet\n * - `{ ok: false; reason }` — refuse to write (symlink escape,\n * non-directory, ancestor escape)\n *\n * Symlink defenses (Codex P1 review on PR #1490): `statSync`\n * follows symlinks, so a starred `mc-*` slug that's actually a\n * symlink to /etc would let the recursive copy below write\n * outside the workspace. Two-layer defense:\n * 1. `lstatSync` to see the link itself; if it's a symlink,\n * only accept when its target stays inside `activeDir`.\n * 2. Always realpath-verify the full path (catches the case\n * where an ancestor like `.claude/` is symlinked even when\n * the slug dir itself is a regular directory). */\nfunction classifyActiveDest(slug: string, destSlugDir: string, activeDir: string): DestVerdict {\n let destInfo;\n try {\n destInfo = lstatSync(destSlugDir);\n } catch {\n return { kind: \"not-active\" };\n }\n if (destInfo.isSymbolicLink()) {\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slot is a symlink whose target escapes activeDir; refusing to write through it\" };\n }\n return { ok: true };\n }\n if (!destInfo.isDirectory()) {\n return { ok: false, reason: \"active slot is occupied by a non-directory; skipping\" };\n }\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slug dir escapes activeDir via an ancestor symlink\" };\n }\n return { ok: true };\n}\n\n/** Per-slug worker for `syncActivePresetSkills`. Extracted to keep\n * the outer function under the `sonarjs/cognitive-complexity`\n * threshold; no behavior difference vs the inline loop. */\nfunction processActiveSlug(slug: string, opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, backupExt: string): void {\n const srcSlugDir = path.join(opts.sourceDir, slug);\n try {\n if (!statSync(srcSlugDir).isDirectory()) return;\n } catch {\n return;\n }\n const destSlugDir = path.join(opts.activeDir, slug);\n const verdict = classifyActiveDest(slug, destSlugDir, opts.activeDir);\n if (\"kind\" in verdict) {\n result.notActive.push(slug);\n return;\n }\n if (!verdict.ok) {\n result.skipped.push(`${slug}: ${verdict.reason}`);\n opts.onWarn?.(\"active preset sync skipped\", { slug, reason: verdict.reason, destSlugDir });\n return;\n }\n const stats = syncDirTreeDiff(srcSlugDir, destSlugDir, backupExt);\n if (stats.updated > 0) {\n result.updated.push(slug);\n opts.onInfo?.(\"active preset skill updated from source\", { slug, files: stats.updated, backupSuffix: backupExt });\n } else if (stats.skipped === 0) {\n result.unchanged.push(slug);\n }\n if (stats.skipped > 0) {\n result.skipped.push(`${slug}: ${stats.skipped} file(s) skipped`);\n opts.onWarn?.(\"active preset skill partial update\", { slug, skipped: stats.skipped });\n }\n}\n\n/** Realpath targets of active symlinks. Used so the prune below never\n * deletes a directory that's serving as a symlink target (e.g. a\n * git-managed checkout the user symlinks an active slug to). */\nfunction collectSymlinkTargets(activeDir: string, entries: readonly string[]): Set<string> {\n const targets = new Set<string>();\n for (const slug of entries) {\n const entryPath = path.join(activeDir, slug);\n try {\n if (lstatSync(entryPath).isSymbolicLink()) targets.add(realpathSync(entryPath));\n } catch {\n /* unreadable — ignore */\n }\n }\n return targets;\n}\n\n/** True iff the active `slug` is a retired preset safe to delete: an\n * `mc-*` real directory inside `activeDir`, with no source preset, and\n * not itself a symlink or a symlink target. */\nfunction isRetiredActiveSlug(slug: string, activeDir: string, sourceSlugs: ReadonlySet<string>, symlinkTargets: ReadonlySet<string>): boolean {\n if (!isPresetSlug(slug) || sourceSlugs.has(slug)) return false;\n const destSlugDir = path.join(activeDir, slug);\n let info;\n try {\n info = lstatSync(destSlugDir);\n } catch {\n return false;\n }\n if (info.isSymbolicLink() || !info.isDirectory()) return false;\n if (!isRealpathInside(destSlugDir, activeDir)) return false;\n try {\n return !symlinkTargets.has(realpathSync(destSlugDir));\n } catch {\n return false;\n }\n}\n\n/** Prune active `mc-*` slugs whose launcher source preset no longer\n * exists (a retired preset). Without this, an upgraded workspace that\n * had starred a since-removed preset (e.g. `mc-manage-sources`) keeps\n * the active copy forever — Claude would still discover a skill whose\n * backend was deleted. Bounded to `mc-*` real directories that stay\n * inside `activeDir` (never a symlink escape, never a user skill). */\nfunction removeRetiredActivePresets(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, sourceSlugs: ReadonlySet<string>): void {\n let activeEntries: string[];\n try {\n activeEntries = readdirSync(opts.activeDir);\n } catch {\n return; // no active dir yet → nothing to prune\n }\n const symlinkTargets = collectSymlinkTargets(opts.activeDir, activeEntries);\n for (const slug of activeEntries) {\n if (!isRetiredActiveSlug(slug, opts.activeDir, sourceSlugs, symlinkTargets)) continue;\n try {\n rmSync(path.join(opts.activeDir, slug), { recursive: true, force: true });\n result.removed.push(slug);\n opts.onInfo?.(\"removed retired active preset skill\", { slug });\n } catch (err) {\n result.skipped.push(`${slug}: prune failed: ${errorMessage(err)}`);\n opts.onWarn?.(\"active preset prune failed\", { slug, reason: errorMessage(err) });\n }\n }\n}\n\n/** Refresh every already-starred `mc-*` preset's active copy in\n * `<workspaceRoot>/.claude/skills/<slug>/` to match the source.\n * Per-file diff with `.bak.<timestamp>` backup on overwrite. Slugs\n * that aren't starred yet are listed in `notActive` but never\n * auto-created. */\nexport function syncActivePresetSkills(opts: SyncActivePresetSkillsOptions): SyncActivePresetSkillsResult {\n const result: SyncActivePresetSkillsResult = { updated: [], unchanged: [], notActive: [], removed: [], skipped: [], backupSuffix: null };\n if (!existsSync(opts.sourceDir)) return result;\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source path stat failed: ${errorMessage(err)}`);\n }\n if (!sourceInfo.isDirectory()) {\n return abortActiveSync(opts, result, \"source path exists as a non-directory; active preset sync skipped\");\n }\n let sourceEntries: string[];\n try {\n sourceEntries = readdirSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source readdir failed: ${errorMessage(err)}`);\n }\n // Single timestamp per run so a multi-file update produces sibling\n // backups with a matching suffix — easier to grep / restore.\n const backupExt = `.bak.${Date.now()}`;\n result.backupSuffix = backupExt;\n const sourceSlugs = new Set<string>();\n for (const slug of sourceEntries) {\n if (slug.startsWith(\".\")) continue;\n if (!isPresetSlug(slug)) continue;\n sourceSlugs.add(slug);\n processActiveSlug(slug, opts, result, backupExt);\n }\n // Prune active copies of presets the launcher no longer ships.\n removeRetiredActivePresets(opts, result, sourceSlugs);\n if (result.updated.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"active preset skills synced\", {\n updated: result.updated.length,\n unchanged: result.unchanged.length,\n notActive: result.notActive.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n backupSuffix: backupExt,\n });\n }\n return result;\n}\n","// Resolve the package's BUNDLED assets (shipped via package.json `files`) and seed\n// them into a workspace. ESM-only: `import.meta.url` points at this module under\n// `dist/workspace-setup/`, so `../../assets` is the package's assets dir at the\n// package root. (The workspace-setup entries build ESM only — `import.meta.url`\n// isn't available under CJS; both hosts run the server as ESM via tsx.)\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { copyFileSync, mkdirSync, readdirSync } from \"node:fs\";\n\nconst ASSETS_DIR = fileURLToPath(new URL(\"../../assets\", import.meta.url));\n\n/** The bundled help-docs source dir (`assets/helps/`). */\nexport function helpsAssetDir(): string {\n return path.join(ASSETS_DIR, \"helps\");\n}\n\n/** The bundled preset-skills source dir (`assets/skills-preset/`) — pass as the\n * `sourceDir` of `syncPresetSkills` / `syncActivePresetSkills`. */\nexport function presetSkillsAssetDir(): string {\n return path.join(ASSETS_DIR, \"skills-preset\");\n}\n\n/** Copy every bundled help doc into `destDir` (created if missing). Idempotent —\n * overwrites on each call so the help docs always track the package's version. */\nexport function seedHelps(opts: { destDir: string }): void {\n mkdirSync(opts.destDir, { recursive: true });\n const src = helpsAssetDir();\n for (const file of readdirSync(src)) {\n copyFileSync(path.join(src, file), path.join(opts.destDir, file));\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/workspace-setup/sync.ts","../../src/workspace-setup/assets.ts"],"sourcesContent":["// Preset skills bundled with mulmoclaude.\n//\n// History: introduced in #1210 to ship launcher-managed \"factory\"\n// skills like `mc-library`. Originally synced straight into\n// `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset\n// auto-active and inflated the Claude system prompt as new presets\n// landed.\n//\n// #1335 PR-A flipped the destination to the catalog\n// (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog\n// entries are visible to UI / tooling but NOT discovered by Claude\n// Code's slash-command resolver — they don't enter the system\n// prompt unless the user (or a later UI in PR-B) explicitly copies\n// one into `.claude/skills/`.\n//\n// The launcher overwrites catalog entries unconditionally on every\n// boot — they're factory defaults, not user state. Anything in\n// `.claude/skills/` (active layer) is left untouched.\n//\n// `syncPresetSkills(...)` is exported as a pure-ish helper (takes\n// paths + a logger sink, returns a summary) so tests can drive it\n// against tmpdirs without touching a real workspace.\n\nimport { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, type Dirent } from \"node:fs\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { PRESET_SLUG_PREFIX, isPresetSlug } from \"./slug.js\";\n\n// Inlined (was the host's ../utils/errors.js) so the package has no host coupling.\nfunction errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n// Recursively mirror `srcDir` into `destDir`. Used by the preset\n// sync so a preset skill that ships sibling assets (e.g.\n// `schema.json` for schema-driven apps, `templates/*.html`) gets\n// copied alongside `SKILL.md` rather than silently dropped. Only\n// regular files and directories are followed — symlinks / FIFOs /\n// sockets are skipped because the preset tree is launcher-managed\n// and shouldn't contain them.\nfunction copyDirTreeSync(srcDir: string, destDir: string): void {\n mkdirSync(destDir, { recursive: true });\n for (const entry of readdirSync(srcDir, { withFileTypes: true })) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n copyDirTreeSync(srcPath, destPath);\n } else if (entry.isFile()) {\n copyFileSync(srcPath, destPath);\n }\n }\n}\n\nconst SKILL_FILENAME = \"SKILL.md\";\n\nexport interface SyncPresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Destination directory:\n * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog\n * half of the catalog-vs-active split — entries here are visible\n * to UI but NOT to Claude Code's prompt-time skill resolver. */\n destDir: string;\n /** Logger callbacks — kept injectable so tests don't need to\n * spin up the structured logger. The boot-side wrapper threads\n * these through to `log.info` / `log.warn`. */\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncPresetSkillsResult {\n /** Slugs successfully copied (or refreshed) from source to dest. */\n copied: string[];\n /** Slugs removed from dest because they no longer exist in source.\n * Bounded to `mc-*` entries — user-authored slugs are never\n * considered for removal. */\n removed: string[];\n /** Source entries that failed validation (wrong prefix, missing\n * SKILL.md, etc.) and were skipped. Each entry is human-readable. */\n skipped: string[];\n}\n\n// Classification of one source entry. `silent` distinguishes\n// structural skips (hidden files, non-directory entries — not the\n// dev's fault) from misconfigurations (bad slug, missing or\n// non-regular SKILL.md — the dev WANTS to know). The boolean lives\n// on the verdict so the caller never has to string-match `reason`,\n// which would silently drop warnings if reason wording changed\n// (CodeRabbit review).\ntype Verdict = { ok: true } | { ok: false; reason: string; silent: boolean };\n\nfunction classifySourceEntry(sourceDir: string, entry: string): Verdict {\n if (entry.startsWith(\".\")) return { ok: false, reason: \"hidden\", silent: true };\n const slugDir = path.join(sourceDir, entry);\n let dirInfo;\n try {\n dirInfo = statSync(slugDir);\n } catch {\n return { ok: false, reason: \"stat failed\", silent: true };\n }\n if (!dirInfo.isDirectory()) return { ok: false, reason: \"not a directory\", silent: true };\n if (!isPresetSlug(entry)) return { ok: false, reason: `slug must start with \"${PRESET_SLUG_PREFIX}\"`, silent: false };\n // Validate SKILL.md is a regular file — `existsSync` alone\n // accepts a directory at that path, which would then crash\n // copyFileSync. Codex review caught this edge case.\n const skillPath = path.join(slugDir, SKILL_FILENAME);\n let skillInfo;\n try {\n skillInfo = statSync(skillPath);\n } catch {\n return { ok: false, reason: `missing ${SKILL_FILENAME}`, silent: false };\n }\n if (!skillInfo.isFile()) return { ok: false, reason: `${SKILL_FILENAME} must be a regular file`, silent: false };\n return { ok: true };\n}\n\n/** Prepare the destination slug dir. Returns false if the slot is\n * occupied by a regular file (local corruption / hand edits) — the\n * caller logs + skips so one bad entry can't crash the whole boot\n * (Codex review iter-1). */\nfunction ensureDestSlugDir(destSlugDir: string): boolean {\n let info;\n try {\n info = statSync(destSlugDir);\n } catch {\n mkdirSync(destSlugDir, { recursive: true });\n return true;\n }\n return info.isDirectory();\n}\n\n/** Filesystem ops `_replaceSlugTree` depends on — injectable so the\n * mid-swap failure path can be regression-tested without real IO faults. */\nexport interface SlugTreeFsOps {\n copyTree: (src: string, dest: string) => void;\n rename: (from: string, dest: string) => void;\n remove: (target: string) => void;\n exists: (target: string) => boolean;\n}\n\nconst REAL_FS_OPS: SlugTreeFsOps = {\n copyTree: copyDirTreeSync,\n rename: renameSync,\n remove: (target) => rmSync(target, { recursive: true, force: true }),\n exists: existsSync,\n};\n\n/** Refresh one preset slot as a rollback-safe stage-and-swap. Wipe-and-replace\n * (not merge) so stale sibling assets — e.g. a schema.json dropped between\n * releases — don't linger; the catalog preset slot is launcher-owned, so user\n * edits there are not preserved across boots.\n *\n * The live slot is never left without a recoverable copy:\n * 1. Stage: copy the source into a temp sibling. A copy failure leaves the\n * existing preset untouched (temp dir removed).\n * 2. Move the existing tree ASIDE to a backup (rename, not delete), so the\n * old contents survive even if the next step fails.\n * 3. Move the staged copy into place. On failure, restore from the backup;\n * if even the restore fails, BOTH backup and staging are preserved for\n * manual recovery rather than deleted.\n * 4. On success, drop the backup.\n *\n * Exported with a `_` prefix only so the rename-failure-after-move path can be\n * regression-tested via the injected `fsOps`. Throws on failure (caller records\n * + skips). */\nexport function _replaceSlugTree(sourceSlugDir: string, destSlugDir: string, fsOps: SlugTreeFsOps = REAL_FS_OPS): void {\n const staging = `${destSlugDir}.tmp-${randomUUID()}`;\n try {\n fsOps.copyTree(sourceSlugDir, staging);\n } catch (err) {\n fsOps.remove(staging); // dest untouched\n throw err;\n }\n const backup = `${destSlugDir}.bak-${randomUUID()}`;\n let backedUp = false;\n if (fsOps.exists(destSlugDir)) {\n try {\n fsOps.rename(destSlugDir, backup);\n backedUp = true;\n } catch (err) {\n fsOps.remove(staging); // dest untouched; nothing was moved\n throw err;\n }\n }\n try {\n fsOps.rename(staging, destSlugDir);\n } catch (err) {\n if (backedUp) {\n try {\n fsOps.rename(backup, destSlugDir); // roll back to the previous tree\n fsOps.remove(staging);\n } catch {\n // Rollback failed — preserve BOTH backup and staging for manual\n // recovery rather than leaving the slot empty.\n }\n } else {\n fsOps.remove(staging); // dest never existed; nothing to restore\n }\n throw err;\n }\n if (backedUp) fsOps.remove(backup);\n}\n\nfunction copySourcesIntoDest(sourceDir: string, destDir: string, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): Set<string> {\n // `keep` is the set of slugs the retirement pass must NOT prune. It tracks\n // valid SOURCE slugs (present + usable dest slot), NOT successful copies — so\n // a slug whose refresh fails transiently keeps its existing contents (left\n // intact by the stage-and-swap above) instead of being pruned as \"retired\".\n const keep = new Set<string>();\n for (const entry of readdirSync(sourceDir)) {\n const verdict = classifySourceEntry(sourceDir, entry);\n if (!verdict.ok) {\n if (!verdict.silent) {\n result.skipped.push(`${entry}: ${verdict.reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason: verdict.reason });\n }\n continue;\n }\n const destSlugDir = path.join(destDir, entry);\n if (!ensureDestSlugDir(destSlugDir)) {\n const reason = \"destination slot occupied by a non-directory; skipping\";\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset entry skipped\", { slug: entry, reason, destSlugDir });\n continue;\n }\n // The slug exists in source with a usable dest slot — keep it regardless of\n // whether the refresh below succeeds, so a transient failure doesn't get it\n // pruned by `removeRetiredPresets`.\n keep.add(entry);\n // Per-slug isolation: a transient IO error / permission issue / partial\n // corruption on one preset must not abort syncing the rest, nor destroy\n // the existing copy. Skip the offender (recorded) and continue.\n try {\n _replaceSlugTree(path.join(sourceDir, entry), destSlugDir);\n result.copied.push(entry);\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n result.skipped.push(`${entry}: ${reason}`);\n opts.onWarn?.(\"preset copy failed, skipping\", { slug: entry, reason });\n }\n }\n return keep;\n}\n\nfunction removeRetiredPresets(destDir: string, keep: ReadonlySet<string>, opts: SyncPresetSkillsOptions, result: SyncPresetSkillsResult): void {\n for (const entry of readdirSync(destDir)) {\n if (!isPresetSlug(entry)) continue;\n if (keep.has(entry)) continue;\n const stalePath = path.join(destDir, entry);\n try {\n if (!statSync(stalePath).isDirectory()) continue;\n } catch {\n continue;\n }\n rmSync(stalePath, { recursive: true, force: true });\n result.removed.push(entry);\n opts.onInfo?.(\"removed retired preset skill\", { slug: entry });\n }\n}\n\n/** Copy every preset slug from `sourceDir` into `destDir` (the\n * preset slot under the catalog root), then remove any `mc-*`\n * entries in `destDir` that no longer have a source. The catalog\n * preset subdir is fully launcher-owned, so the `mc-*` prefix\n * check at destination is defence-in-depth: a stray non-preset\n * slug landing in `catalog/preset/` is unexpected, and we'd\n * rather skip it than silently delete a directory we don't\n * recognise. */\nexport function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {\n const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };\n if (!existsSync(opts.sourceDir)) {\n // No preset directory in the launcher tarball — nothing to do.\n // This is the legitimate \"no presets shipped yet\" state.\n return result;\n }\n // Source-side validation: the launcher's preset path COULD exist\n // as a regular file (a packaging bug, a corrupted install). Without\n // this guard, `readdirSync(sourceDir)` would throw ENOTDIR and\n // crash boot. Codex review iter-3.\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n const reason = `source path stat failed: ${errorMessage(err)}`;\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n if (!sourceInfo.isDirectory()) {\n const reason = \"source path exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n }\n // The root dest itself can be corrupted into a regular file by a\n // user / external tool; mkdirSync would throw EEXIST and crash\n // boot. Treat it as a recoverable \"skip the entire sync\" state\n // — log a clear warning so the user sees what to fix.\n // (Codex review iter-2.)\n if (!ensureDestSlugDir(opts.destDir)) {\n const reason = \"root dest exists as a non-directory; preset sync skipped\";\n result.skipped.push(`${opts.destDir}: ${reason}`);\n opts.onWarn?.(\"preset sync aborted\", { destDir: opts.destDir, reason });\n return result;\n }\n const keep = copySourcesIntoDest(opts.sourceDir, opts.destDir, opts, result);\n removeRetiredPresets(opts.destDir, keep, opts, result);\n if (result.copied.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"preset skills synced\", {\n copied: result.copied.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------\n// Active-layer sync: keep starred mc-* presets in lockstep with\n// their launcher-bundled source.\n//\n// Motivation: the catalog sync above refreshes\n// `data/skills/catalog/preset/<slug>/` on every boot, but once a\n// user stars an entry the active copy in\n// `<workspace>/.claude/skills/<slug>/` is never updated even when\n// the launcher ships a new SKILL.md (e.g. a typo fix or, as the\n// trigger for this code, the schema-driven-apps → collections\n// rename). The SKILL.md front-matter explicitly says\n// \"do not edit this file in the workspace, it is overwritten on\n// every server boot\" — until this function existed, that was a\n// promise the active layer didn't keep.\n//\n// Safety model:\n// - Only `mc-*` slugs are touched (defensive prefix check —\n// never touches user-authored skills).\n// - Per-file diff: a file is overwritten only if its bytes\n// differ from the source. No-op when already up to date.\n// - User-added files inside an active slug dir are left alone\n// (we walk the source tree, not the dest tree).\n// - If a file IS overwritten, the previous contents are first\n// renamed to `<file>.bak.<timestamp>` so a user who had\n// locally tweaked the preset can recover.\n// - A slug whose active dir doesn't exist (= not starred yet)\n// is skipped entirely, never auto-starred.\n// ---------------------------------------------------------------\n\nexport interface SyncActivePresetSkillsOptions {\n /** Source directory: `<launcher>/server/workspace/skills-preset/`. */\n sourceDir: string;\n /** Active skills directory: `<workspaceRoot>/.claude/skills/`. */\n activeDir: string;\n onInfo?: (message: string, data?: Record<string, unknown>) => void;\n onWarn?: (message: string, data?: Record<string, unknown>) => void;\n}\n\nexport interface SyncActivePresetSkillsResult {\n /** Slugs whose active copy had at least one file overwritten. */\n updated: string[];\n /** Slugs whose active copy already matched the source — no-op. */\n unchanged: string[];\n /** Slugs that haven't been starred yet (no active dir present).\n * Listed for diagnostics; the function never auto-stars. */\n notActive: string[];\n /** Active `mc-*` slugs pruned because the launcher no longer ships a\n * source preset for them (a retired preset). Bounded to `mc-*` —\n * user-authored skills are never removed. */\n removed: string[];\n /** Per-slug failure messages (permission errors, etc.). */\n skipped: string[];\n /** Common timestamp suffix used for every backup file produced by\n * this run. Exposed so the boot-time log can point a user at the\n * exact glob to inspect. */\n backupSuffix: string | null;\n}\n\ntype FileSyncOutcome = \"updated\" | \"unchanged\" | \"skipped\";\n\nfunction filesEqual(left: string, right: string): boolean {\n try {\n return readFileSync(left).equals(readFileSync(right));\n } catch {\n return false;\n }\n}\n\n/** Copy `srcPath` over `destPath`. If `destPath` already exists and\n * differs, rename it to `destPath + backupExt` first so the user's\n * prior contents are recoverable. If `destPath` doesn't exist, just\n * copy (no backup needed). Pure: no logging here — caller decides. */\nfunction syncOneFile(srcPath: string, destPath: string, backupExt: string): FileSyncOutcome {\n let destExists: boolean;\n try {\n destExists = statSync(destPath).isFile();\n } catch {\n destExists = false;\n }\n if (!destExists) {\n try {\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n }\n if (filesEqual(srcPath, destPath)) return \"unchanged\";\n try {\n renameSync(destPath, destPath + backupExt);\n copyFileSync(srcPath, destPath);\n return \"updated\";\n } catch {\n return \"skipped\";\n }\n}\n\ninterface DirSyncStats {\n updated: number;\n unchanged: number;\n skipped: number;\n}\n\nfunction syncDirTreeDiff(srcDir: string, destDir: string, backupExt: string): DirSyncStats {\n const stats: DirSyncStats = { updated: 0, unchanged: 0, skipped: 0 };\n try {\n mkdirSync(destDir, { recursive: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n let entries: Dirent[];\n try {\n entries = readdirSync(srcDir, { withFileTypes: true });\n } catch {\n stats.skipped++;\n return stats;\n }\n for (const entry of entries) {\n const srcPath = path.join(srcDir, entry.name);\n const destPath = path.join(destDir, entry.name);\n if (entry.isDirectory()) {\n const sub = syncDirTreeDiff(srcPath, destPath, backupExt);\n stats.updated += sub.updated;\n stats.unchanged += sub.unchanged;\n stats.skipped += sub.skipped;\n } else if (entry.isFile()) {\n const outcome = syncOneFile(srcPath, destPath, backupExt);\n stats[outcome]++;\n }\n // Symlinks / sockets / FIFOs intentionally ignored (the launcher\n // preset tree shouldn't contain them).\n }\n return stats;\n}\n\nfunction abortActiveSync(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, reason: string): SyncActivePresetSkillsResult {\n result.skipped.push(`${opts.sourceDir}: ${reason}`);\n opts.onWarn?.(\"active preset sync aborted\", { sourceDir: opts.sourceDir, reason });\n return result;\n}\n\n/** True iff `absPath`'s realpath resolves inside `rootPath`'s\n * realpath. Defends `processActiveSlug` against a starred `mc-*`\n * slug that's actually a symlink to somewhere outside\n * `activeDir`: without this check, the recursive copy below would\n * follow the symlink and write through to the link's target\n * (potentially anywhere on disk). Returns false on any error so\n * the caller treats unreadable paths as \"refused\" rather than\n * \"OK to write\". */\nfunction isRealpathInside(absPath: string, rootPath: string): boolean {\n try {\n const real = realpathSync(absPath);\n const rootReal = realpathSync(rootPath);\n return real === rootReal || real.startsWith(rootReal + path.sep);\n } catch {\n return false;\n }\n}\n\ntype DestVerdict = { ok: true } | { ok: false; reason: string } | { kind: \"not-active\" };\n\n/** Validate the active dest slug dir before writing through it.\n * Returns:\n * - `{ ok: true }` — proceed with the sync\n * - `{ kind: \"not-active\" }` — slug hasn't been starred yet\n * - `{ ok: false; reason }` — refuse to write (symlink escape,\n * non-directory, ancestor escape)\n *\n * Symlink defenses (Codex P1 review on PR #1490): `statSync`\n * follows symlinks, so a starred `mc-*` slug that's actually a\n * symlink to /etc would let the recursive copy below write\n * outside the workspace. Two-layer defense:\n * 1. `lstatSync` to see the link itself; if it's a symlink,\n * only accept when its target stays inside `activeDir`.\n * 2. Always realpath-verify the full path (catches the case\n * where an ancestor like `.claude/` is symlinked even when\n * the slug dir itself is a regular directory). */\nfunction classifyActiveDest(slug: string, destSlugDir: string, activeDir: string): DestVerdict {\n let destInfo;\n try {\n destInfo = lstatSync(destSlugDir);\n } catch {\n return { kind: \"not-active\" };\n }\n if (destInfo.isSymbolicLink()) {\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slot is a symlink whose target escapes activeDir; refusing to write through it\" };\n }\n return { ok: true };\n }\n if (!destInfo.isDirectory()) {\n return { ok: false, reason: \"active slot is occupied by a non-directory; skipping\" };\n }\n if (!isRealpathInside(destSlugDir, activeDir)) {\n return { ok: false, reason: \"active slug dir escapes activeDir via an ancestor symlink\" };\n }\n return { ok: true };\n}\n\n/** Per-slug worker for `syncActivePresetSkills`. Extracted to keep\n * the outer function under the `sonarjs/cognitive-complexity`\n * threshold; no behavior difference vs the inline loop. */\nfunction processActiveSlug(slug: string, opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, backupExt: string): void {\n const srcSlugDir = path.join(opts.sourceDir, slug);\n try {\n if (!statSync(srcSlugDir).isDirectory()) return;\n } catch {\n return;\n }\n const destSlugDir = path.join(opts.activeDir, slug);\n const verdict = classifyActiveDest(slug, destSlugDir, opts.activeDir);\n if (\"kind\" in verdict) {\n result.notActive.push(slug);\n return;\n }\n if (!verdict.ok) {\n result.skipped.push(`${slug}: ${verdict.reason}`);\n opts.onWarn?.(\"active preset sync skipped\", { slug, reason: verdict.reason, destSlugDir });\n return;\n }\n const stats = syncDirTreeDiff(srcSlugDir, destSlugDir, backupExt);\n if (stats.updated > 0) {\n result.updated.push(slug);\n opts.onInfo?.(\"active preset skill updated from source\", { slug, files: stats.updated, backupSuffix: backupExt });\n } else if (stats.skipped === 0) {\n result.unchanged.push(slug);\n }\n if (stats.skipped > 0) {\n result.skipped.push(`${slug}: ${stats.skipped} file(s) skipped`);\n opts.onWarn?.(\"active preset skill partial update\", { slug, skipped: stats.skipped });\n }\n}\n\n/** Realpath targets of active symlinks. Used so the prune below never\n * deletes a directory that's serving as a symlink target (e.g. a\n * git-managed checkout the user symlinks an active slug to). */\nfunction collectSymlinkTargets(activeDir: string, entries: readonly string[]): Set<string> {\n const targets = new Set<string>();\n for (const slug of entries) {\n const entryPath = path.join(activeDir, slug);\n try {\n if (lstatSync(entryPath).isSymbolicLink()) targets.add(realpathSync(entryPath));\n } catch {\n /* unreadable — ignore */\n }\n }\n return targets;\n}\n\n/** True iff the active `slug` is a retired preset safe to delete: an\n * `mc-*` real directory inside `activeDir`, with no source preset, and\n * not itself a symlink or a symlink target. */\nfunction isRetiredActiveSlug(slug: string, activeDir: string, sourceSlugs: ReadonlySet<string>, symlinkTargets: ReadonlySet<string>): boolean {\n if (!isPresetSlug(slug) || sourceSlugs.has(slug)) return false;\n const destSlugDir = path.join(activeDir, slug);\n let info;\n try {\n info = lstatSync(destSlugDir);\n } catch {\n return false;\n }\n if (info.isSymbolicLink() || !info.isDirectory()) return false;\n if (!isRealpathInside(destSlugDir, activeDir)) return false;\n try {\n return !symlinkTargets.has(realpathSync(destSlugDir));\n } catch {\n return false;\n }\n}\n\n/** Prune active `mc-*` slugs whose launcher source preset no longer\n * exists (a retired preset). Without this, an upgraded workspace that\n * had starred a since-removed preset (e.g. `mc-manage-sources`) keeps\n * the active copy forever — Claude would still discover a skill whose\n * backend was deleted. Bounded to `mc-*` real directories that stay\n * inside `activeDir` (never a symlink escape, never a user skill). */\nfunction removeRetiredActivePresets(opts: SyncActivePresetSkillsOptions, result: SyncActivePresetSkillsResult, sourceSlugs: ReadonlySet<string>): void {\n let activeEntries: string[];\n try {\n activeEntries = readdirSync(opts.activeDir);\n } catch {\n return; // no active dir yet → nothing to prune\n }\n const symlinkTargets = collectSymlinkTargets(opts.activeDir, activeEntries);\n for (const slug of activeEntries) {\n if (!isRetiredActiveSlug(slug, opts.activeDir, sourceSlugs, symlinkTargets)) continue;\n try {\n rmSync(path.join(opts.activeDir, slug), { recursive: true, force: true });\n result.removed.push(slug);\n opts.onInfo?.(\"removed retired active preset skill\", { slug });\n } catch (err) {\n result.skipped.push(`${slug}: prune failed: ${errorMessage(err)}`);\n opts.onWarn?.(\"active preset prune failed\", { slug, reason: errorMessage(err) });\n }\n }\n}\n\n/** Refresh every already-starred `mc-*` preset's active copy in\n * `<workspaceRoot>/.claude/skills/<slug>/` to match the source.\n * Per-file diff with `.bak.<timestamp>` backup on overwrite. Slugs\n * that aren't starred yet are listed in `notActive` but never\n * auto-created. */\nexport function syncActivePresetSkills(opts: SyncActivePresetSkillsOptions): SyncActivePresetSkillsResult {\n const result: SyncActivePresetSkillsResult = { updated: [], unchanged: [], notActive: [], removed: [], skipped: [], backupSuffix: null };\n if (!existsSync(opts.sourceDir)) return result;\n let sourceInfo;\n try {\n sourceInfo = statSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source path stat failed: ${errorMessage(err)}`);\n }\n if (!sourceInfo.isDirectory()) {\n return abortActiveSync(opts, result, \"source path exists as a non-directory; active preset sync skipped\");\n }\n let sourceEntries: string[];\n try {\n sourceEntries = readdirSync(opts.sourceDir);\n } catch (err) {\n return abortActiveSync(opts, result, `source readdir failed: ${errorMessage(err)}`);\n }\n // Single timestamp per run so a multi-file update produces sibling\n // backups with a matching suffix — easier to grep / restore.\n const backupExt = `.bak.${Date.now()}`;\n result.backupSuffix = backupExt;\n const sourceSlugs = new Set<string>();\n for (const slug of sourceEntries) {\n if (slug.startsWith(\".\")) continue;\n if (!isPresetSlug(slug)) continue;\n sourceSlugs.add(slug);\n processActiveSlug(slug, opts, result, backupExt);\n }\n // Prune active copies of presets the launcher no longer ships.\n removeRetiredActivePresets(opts, result, sourceSlugs);\n if (result.updated.length > 0 || result.removed.length > 0) {\n opts.onInfo?.(\"active preset skills synced\", {\n updated: result.updated.length,\n unchanged: result.unchanged.length,\n notActive: result.notActive.length,\n removed: result.removed.length,\n skipped: result.skipped.length,\n backupSuffix: backupExt,\n });\n }\n return result;\n}\n","// Resolve the package's BUNDLED assets (shipped via package.json `files`) and seed\n// them into a workspace. ESM-only: `import.meta.url` points at this module under\n// `dist/workspace-setup/`, so `../../assets` is the package's assets dir at the\n// package root. (The workspace-setup entries build ESM only — `import.meta.url`\n// isn't available under CJS; both hosts run the server as ESM via tsx.)\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { copyFileSync, mkdirSync, readdirSync } from \"node:fs\";\n\nconst ASSETS_DIR = fileURLToPath(new URL(\"../../assets\", import.meta.url));\n\n/** The bundled help-docs source dir (`assets/helps/`). */\nexport function helpsAssetDir(): string {\n return path.join(ASSETS_DIR, \"helps\");\n}\n\n/** The bundled preset-skills source dir (`assets/skills-preset/`) — pass as the\n * `sourceDir` of `syncPresetSkills` / `syncActivePresetSkills`. */\nexport function presetSkillsAssetDir(): string {\n return path.join(ASSETS_DIR, \"skills-preset\");\n}\n\n/** Copy every bundled help doc into `destDir` (created if missing). Idempotent —\n * overwrites on each call so the help docs always track the package's version. */\nexport function seedHelps(opts: { destDir: string }): void {\n mkdirSync(opts.destDir, { recursive: true });\n const src = helpsAssetDir();\n for (const file of readdirSync(src)) {\n copyFileSync(path.join(src, file), path.join(opts.destDir, file));\n }\n}\n"],"mappings":"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"}