import http from 'node:http'; import { WearableConnectorFactoryOptions, WearableSourceConnector, WearableConversation, WearableConnectorRegistration } from '@remnic/core'; /** Package-wide constants for @remnic/capture-audio. */ /** * Reported by GET /v1/health. Kept in sync with package.json by the * release tooling; the health endpoint tolerates drift because the * connector never gates on an exact match (it reads `ok`). */ declare const CAPTURE_AUDIO_VERSION = "9.14.0"; /** Loopback default; capture is local-first (charter). */ declare const DEFAULT_HOST = "127.0.0.1"; declare const DEFAULT_PORT = 4340; /** Spool schema version, persisted in the `meta` table. */ declare const SPOOL_SCHEMA_VERSION = 3; /** * Error taxonomy for @remnic/capture-audio. * * Two authored-message classes, mirroring the wearables split * (packages/remnic-core/src/wearables/errors.ts): configuration problems * and caller-correctable input. Both carry operator-safe messages (never * foreign error text, never credentials). The HTTP layer maps * CaptureInputError to 400; anything else is a backend fault (500). */ /** Config load/validation failure — surfaced loudly, never silently defaulted (rule 39). */ declare class CaptureConfigError extends Error { constructor(message: string); } /** Caller-correctable request/CLI input — maps to HTTP 400. */ declare class CaptureInputError extends Error { constructor(message: string); } /** * Daemon config (`~/.remnic/capture/audio.json`), created by * `remnic-capture-audio init`. Strict and loud: an absent field takes the * documented default, but a present-but-invalid value throws * CaptureConfigError (rule 39 — no silent defaulting). Ports are integers * in [1, 65535] (rule 17); booleans coerce boolean-like strings (rule 24). * * Only `whisper-cpp` is currently accepted for STT. VAD configuration maps * directly to the optional Sherpa Silero runtime adapter. */ interface SttConfig { engine: "whisper-cpp"; modelPath: string | null; threads: number | null; } interface VadConfig { modelPath: string | null; minSpeechMs: number; minSilenceMs: number; maxSpeechMs: number; threshold: number; threads: number; } interface DiarizationConfig { similarityThreshold: number; } interface DeviceConfig { mic: string | null; system: string | null; } interface DaemonConfig { host: string; port: number; chunkSeconds: number; captureChannel: "mic" | "system" | "both"; conversationGapMinutes: number; /** * Bounded reorder window, in seconds, for cross-channel arrival skew * (issue #2145). Chunks are held until the newest observed chunk end is * this far past their own end, then released oldest-first, so a delayed * system chunk is grouped with the conversation it belongs to instead of * a later mic chunk's. 0 disables buffering: every chunk is released on * arrival, which is the pre-#2145 behavior. */ reorderWindowSeconds: number; rawRetentionHours: number; spoolRetentionDays: number; vad: VadConfig; diarization: DiarizationConfig; stt: SttConfig; denyApps: string[]; devices: DeviceConfig; } declare function defaultDaemonConfig(): DaemonConfig; declare function parseDaemonConfig(raw: unknown): DaemonConfig; declare function loadDaemonConfig(configPath: string): DaemonConfig; declare function serializeDaemonConfig(cfg: DaemonConfig): string; /** Filesystem layout for the capture working directory. */ interface CapturePaths { baseDir: string; configPath: string; spoolPath: string; tokenPath: string; pidPath: string; logPath: string; } /** * Root of the capture working directory. `REMNIC_CAPTURE_DIR` overrides * the default `~/.remnic/capture` (tests and multi-instance setups point * it at a scratch dir). A leading `~` expands to the home directory. */ declare function captureBaseDir(env?: NodeJS.ProcessEnv): string; declare function capturePaths(baseDir?: string): CapturePaths; /** * Bearer-token lifecycle. The daemon auto-generates a 256-bit token on * first use and stores it 0600; a pre-existing file is re-chmod'd 0600 * defensively because a world-readable token is a credential leak. The * token is REQUIRED on every request when the daemon binds a non-loopback * host (see daemon.ts); on loopback it exists but localhost is trusted. */ declare function generateToken(): string; declare function loadOrCreateToken(tokenPath: string): string; /** Constant-time compare; unequal lengths short-circuit to false. */ declare function tokensMatch(expected: string, presented: string): boolean; /** Parse `Authorization: Bearer `; returns null when absent/malformed. */ declare function bearerFromHeader(header: string | string[] | undefined): string | null; /** * Request-input validation for the HTTP surface. Every failure raises * CaptureInputError, which the daemon maps to HTTP 400 — invalid date, * timezone, limit, or cursor is rejected loudly, never silently defaulted * (rule 39). The keyset cursor is an opaque base64url token over the * (started_at_utc, id) tuple the conversations query orders by. */ /** Validate a YYYY-MM-DD calendar date (rejects e.g. 2026-02-30). */ declare function parseTranscriptDate(value: string | null | undefined): string; /** Validate an IANA timezone by attempting to build a formatter for it. */ declare function assertValidTimezone(value: string | null | undefined): string; /** Absent limit → default; present-but-invalid → 400. */ declare function parseLimit(value: string | null | undefined): number; interface Cursor { startedAtUtc: string; id: string; } declare function encodeCursor(startedAtUtc: string, id: string): string; /** Absent cursor → null (first page); malformed cursor → 400. */ declare function decodeCursor(value: string | null | undefined): Cursor | null; /** * Native capture helper resolver + supervised process runner (issue #1897, * "audio native macOS helper" slice — Node side only). * * The native recorder is the ONE shared macOS helper shipped by #2138 * (`remnic-capture-helper`), driven here through its `audio-capture` * subcommand. It emits one JSONL `ChunkEvent` per recorded WAV chunk on * stdout. This module is deliberately à-la-carte, mirroring the VAD/STT * adapters and the screen daemon's helper seam: * * - The helper ships as an OPTIONAL, per-platform package * (`@remnic/capture-native-darwin-arm64` / `-x64`) that exports a * `helperBinaryPath` and declares the same binary under `bin`. It is a * peer dependency, never a runtime dependency, so `@remnic/capture-audio` * installs and works on any platform without it. * - The package specifier is COMPUTED from `process.platform`/`arch` so a * static importer never bundles a foreign-arch binary, and resolution uses * Node module resolution (`require.resolve`). * - `REMNIC_CAPTURE_HELPER_BIN` overrides resolution with an explicit binary * path (manual installs and the hardware-free test seam, which points it at * a fake script emitting canned JSON). * - A missing optional package reports the EXACT install command instead of a * raw resolver error. * * The runner is the sole owner of the child process: it spawns the helper, * parses stdout strictly line-by-line, reports validated events to a callback, * reports stderr/errors separately, and restarts only UNEXPECTED exits with * bounded exponential backoff. It never writes the Spool and never invents a * conversation — the processing/assembly layer owns eventual Spool writes * downstream of the validated events this runner surfaces. */ /** One recorded audio chunk, as emitted by the native helper on stdout (JSONL). */ interface ChunkEvent { path: string; channel: "mic" | "system"; startedAtUtc: string; endedAtUtc: string; device: string | null; } /** Which channels the `audio-capture` subcommand records. */ type ChannelSelection = "mic" | "system" | "both"; /** A resolved native helper: its source specifier and the on-disk binary path. */ interface HelperResolution { specifier: string; binaryPath: string; } /** The narrow child-process surface the runner depends on (injectable for tests). */ interface HelperChild { stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown; }; stderr: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown; }; once(event: "error", listener: (err: Error) => void): unknown; once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; kill(signal?: NodeJS.Signals): boolean; readonly killed?: boolean; readonly pid?: number; } /** Spawns the helper binary. Defaults to a `node:child_process` adapter. */ type HelperSpawn = (binaryPath: string, args: string[]) => HelperChild; /** An opaque restart-timer token returned by `scheduleRestart`. */ type RestartTimer = unknown; interface ResolveHelperDeps { platform?: NodeJS.Platform; arch?: string; /** `require.resolve`-style resolver; defaults to this module's require. */ resolve?: (specifier: string) => string; readFile?: (file: string) => string; /** Environment source for the `REMNIC_CAPTURE_HELPER_BIN` override. */ env?: NodeJS.ProcessEnv; } interface NativeRunnerOptions { /** Directory the helper writes WAV chunks into (`audio-capture --out`). */ outDir: string; chunkSeconds: number; /** Channels to record; defaults to "both". */ channel?: ChannelSelection; /** Optional CoreAudio microphone device UID (`--device`). */ device?: string | null; /** Called once per validated ChunkEvent. */ onChunk: (event: ChunkEvent) => void; /** Called for a rejected stdout line or a spawn/child error. */ onError?: (error: Error) => void; /** Called once per complete stderr line. */ onStderr?: (line: string) => void; /** Pre-resolved helper; when absent the runner resolves it lazily on `start()`. */ resolution?: HelperResolution; resolveBinary?: (deps: ResolveHelperDeps) => HelperResolution; spawn?: HelperSpawn; /** Max consecutive unexpected restarts before giving up (default 5). */ maxRestarts?: number; /** First backoff delay in ms (default 500). */ baseBackoffMs?: number; /** Backoff ceiling in ms (default 30000). */ maxBackoffMs?: number; scheduleRestart?: (fn: () => void, delayMs: number) => RestartTimer; cancelRestart?: (timer: RestartTimer) => void; } /** A running native-capture supervisor. */ interface NativeCaptureRunner { start(): void; /** Stop the helper (SIGTERM) and resolve once it exits and its final chunk is read. */ stop(): Promise; /** True between a `start()` and its matching `stop()`. */ readonly running: boolean; } /** The env var that overrides package resolution with an explicit binary path. */ declare const HELPER_BIN_ENV = "REMNIC_CAPTURE_HELPER_BIN"; /** * Compute the optional native-helper package specifier for a platform/arch. * The helper is macOS-only and hardware-gated; every other platform (and any * unsupported macOS architecture) throws loudly rather than resolving to a * package that cannot exist. */ declare function helperPackageSpecifier(platform: NodeJS.Platform | string, arch: string): string; /** * Resolve the native helper binary. Order: explicit `REMNIC_CAPTURE_HELPER_BIN` * override, then the computed platform package's declared executable (resolved * via Node module resolution, identical to its `helperBinaryPath` export). * Throws a CaptureConfigError naming the exact install command when the * optional package is not installed. */ declare function resolveHelperBinary(deps?: ResolveHelperDeps): HelperResolution; /** Build the `audio-capture` argv from runner options (#2138 helper contract). */ declare function buildHelperArgs(opts: Pick): string[]; /** Parse and validate one JSONL line into a ChunkEvent; throws on anything malformed. */ declare function parseChunkEvent(line: string): ChunkEvent; /** * Run the helper's one-shot `device-enumerate` subcommand and return the parsed * device list. Bounded, argv-only, and injectable for tests. Throws a * CaptureInputError on a nonzero exit, empty output, or invalid JSON. */ declare function enumerateDevices(binaryPath: string, spawn?: HelperSpawn, timeoutMs?: number): Promise; /** * Create a supervised native-capture runner. Dependency-injectable: pass * `spawn`, `resolution`/`resolveBinary`, and `scheduleRestart`/`cancelRestart` * to drive it deterministically in tests. */ declare function createNativeCaptureRunner(options: NativeRunnerOptions): NativeCaptureRunner; /** Ceiling on chunks held in the reorder buffer. */ declare const MAX_BUFFERED_CHUNKS = 512; /** Consecutive apply failures before a chunk is parked. */ declare const QUARANTINE_AFTER_FAILURES = 3; type PendingChunkReason = "evicted" | "quarantined"; interface PendingChunkInput { id: string; wavPath: string; startedAtUtc: string; endedAtUtc: string; channel: ChunkEvent["channel"]; device: string | null; reason: PendingChunkReason; } interface PendingChunkRecord extends PendingChunkInput { createdAtUtc: string; } /** * SQLite spool — the daemon's local buffer of captured conversations. * * Uses the built-in `node:sqlite` driver (no native dependency), keeping * @remnic/capture-audio à-la-carte: installing it pulls zero extra runtime * packages. WAL mode + foreign keys are enabled per connection. * * Schema (names/semantics fixed by issue #1897): * chunks(id, channel, device, started_at_utc, ended_at_utc, status, wav_path) * segments(id, chunk_id FK, conversation_id FK, speaker_cluster, is_wearer, * channel, text, start_utc, end_utc, ordinal) * conversations(id, started_at_utc, ended_at_utc, state, segment_count) * speaker_clusters(id, label, centroid, example_embeddings, embedding_count, is_self) * meta(key, value) * * The public read API (`queryFinalConversations`) serves ONLY `final` * conversations, ordered by a stable keyset (started_at_utc, id) so the * connector never ingests half a meeting and pagination is deterministic * even when two conversations share a start timestamp. */ type ConversationState = "capturing" | "final"; type ChunkStatus = "pending" | "transcribed" | "failed" | "deleted"; interface SegmentInput { speakerCluster?: string | null; isWearer?: boolean; /** * Speaker embedding for this segment, persisted as a JSON BLOB (issue * #2145). Clustering runs at finalize over the segments that SURVIVE * cross-channel dedup, so a pruned loopback duplicate never inflates a * cluster's centroid or count. */ embedding?: readonly number[] | null; channel: string; text: string; startUtc: string; endUtc: string; } interface ConversationInput { id?: string; startedAtUtc: string; endedAtUtc?: string | null; state?: ConversationState; device?: string | null; chunkStatus?: ChunkStatus; wavPath?: string | null; segments: SegmentInput[]; } interface SpeakerInput { id: string; label?: string | null; isSelf?: boolean; embeddingCount?: number; /** Speaker embedding centroid; persisted as a JSON BLOB for restart-stable ids. */ centroid?: readonly number[] | null; /** Bounded diverse example embeddings; persisted as a JSON BLOB. */ examples?: readonly (readonly number[])[] | null; } interface SpeakerClusterRow { id: string; label: string | null; isSelf: boolean; embeddingCount: number; centroid: number[]; examples: number[][]; } interface DaemonSegment { textRaw: string; speakerKey: string | null; isWearer: boolean; channel: string; startUtc: string; endUtc: string; } interface DaemonConversation { id: string; startedAtUtc: string; endedAtUtc: string | null; state: ConversationState; segmentCount: number; segments: DaemonSegment[]; } interface ConversationPage { conversations: DaemonConversation[]; nextCursor: string | null; } interface SpeakerRow { id: string; label: string | null; isSelf: boolean; embeddingCount: number; } interface QueryFinalOptions { date: string; timezone: string; cursor?: string | null; limit: number; } interface AssemblyAppendInput { /** Durable dedup marker for one application (e.g. a transcribed chunk id). */ idempotencyKey: string; /** Stable `conv_` id from the assembler. */ conversationId: string; /** Conversation start; used only when the conversation is first created. */ startedAtUtc: string; /** Defaults to `capturing`; the conversation is finalized by a later call. */ state?: ConversationState; device?: string | null; /** Backing chunk row id; defaults to `idempotencyKey`. */ chunkId?: string; /** Backing WAV path recorded on the chunk row; retained for the janitor/audit. */ wavPath?: string | null; segments: SegmentInput[]; } interface AssemblyAppendResult { /** False when `idempotencyKey` was already applied (a replay no-op). */ applied: boolean; conversationId: string; /** Total segments in the conversation after this call. */ segmentCount: number; } declare class Spool { #private; constructor(location: string); close(): void; meta(key: string): string | null; setMeta(key: string, value: string): void; /** * Insert (or replace) a whole conversation with its segments and a * backing chunk row, atomically. Idempotent by conversation id: * re-ingesting the same id deletes the prior rows first, so a repeated * replay is a content no-op (kill-9 restart safety, acceptance criteria). */ insertConversation(input: ConversationInput): string; /** Flip every still-open conversation to `final` (daemon stop / gap timeout). */ finalizeOpenConversations(): number; /** * Durably append one transcribed chunk's segments to a conversation, * idempotent on `idempotencyKey` (a replay/restart of the same chunk is a * no-op). Creates the conversation as `capturing` on first append; a later * `finalizeConversation`/`finalizeOpenConversations` flips it to `final`. */ appendAssembledSegments(input: AssemblyAppendInput): AssemblyAppendResult; /** Flip one conversation to `final`; returns true when it was capturing. */ finalizeConversation(id: string): boolean; /** * A conversation's segments in the shape cross-channel dedup needs (segment * id + DedupSegment fields), chronological. Used to prune loopback duplicates * at finalization, which is order-independent (all segments are present). */ conversationSegmentsForDedup(conversationId: string): Array<{ id: string; channel: string; text: string; startUtc: string; endUtc: string; }>; /** * Delete specific segments (dedup prune), keeping each owning conversation's * segment_count in sync. Returns the number actually removed. */ deleteSegments(ids: readonly string[]): number; /** * Segments of one conversation that still need a speaker, chronological. * * Only rows with a stored embedding and no cluster yet: clustering runs at * finalize over the segments that SURVIVED dedup (issue #2145), and skipping * already-assigned rows keeps a repeated finalize from double-counting a * centroid. */ conversationSegmentsForDiarization(conversationId: string): Array<{ id: string; channel: string; embedding: number[]; }>; /** * Commit one conversation's diarization: cluster snapshots and the segment * assignments that produced them, in ONE transaction. * * Splitting the two lets a crash persist an updated `embedding_count` while * its segments stay unassigned; the next finalize would select the same rows * and count the same embeddings again (issue #2145). Atomicity is what makes * the repeated-finalize idempotency claim true. */ commitDiarization(input: { clusters: readonly SpeakerInput[]; assignments: ReadonlyArray<{ id: string; speakerCluster: string; isWearer: boolean; }>; }): number; /** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */ capturingConversationIds(): string[]; /** * Record a bare idempotency marker (no segments). * * Used to persist facts a later replay cannot re-derive — such as how many * segments a chunk's transcript produced, which is the only way to tell a * legitimately shorter retranscription from a missing tail (issue #2145). */ markApplied(idempotencyKey: string, conversationId: string): void; /** * Whether ANY idempotency key for this chunk was applied. * * Only a SILENT replay needs this — a chunk partially applied by a binary * predating the transcript manifest has no manifest to compare, and a * zero-segment replay has no per-segment key to look up exactly. Speech * chunks use the indexed manifest lookup below, so continuous capture never * pays for this scan (issue #2145). */ hasAppliedChunkPrefix(chunkIdPrefix: string): boolean; /** * Conversations a chunk actually contributed stored segments to. * * Used to scope a rebuilt replay hold to the prefix that chunk belongs to, * rather than to every conversation that happens to be capturing (#2145). * Matches the bare chunk id and every per-segment or per-group derivative * (`:h`, and the pre-manifest `:`). */ conversationIdsForChunk(chunkId: string): string[]; /** * Chunks whose transcript manifest is recorded but which never completed. * * A restart loses the in-memory record of which chunks are still awaiting a * replay, so it is re-derived from these two durable markers: the manifest is * written before any append, `:done` only after every segment is stored * (issue #2145). */ incompleteChunkIds(): string[]; /** * The value stored alongside an idempotency marker, or `undefined`. * * `markApplied` uses this column to carry a fact a replay cannot re-derive — * the chunk's transcript manifest hash — and this is the exact, primary-key * lookup that reads it back (issue #2145). */ appliedChunkValue(idempotencyKey: string): string | undefined; /** Whether a chunk with this idempotency key was already durably applied. */ isChunkApplied(idempotencyKey: string): boolean; /** * Record that a whole chunk finished (every group appended) via a `:done` * marker, so a later full replay can skip transcription + diarization. A crash * before this leaves no marker, so the missing groups re-append on replay. */ markChunkComplete(chunkId: string, conversationId: string): void; /** * The newest still-`capturing` conversation, so a chunk arriving after a * process restart continues it (subject to the assembler's gap rule) instead * of splitting off a new one. Null when none is open. */ latestCapturingConversation(): { id: string; startedAtUtc: string; endedAtUtc: string; } | null; /** One capturing conversation by id, for resuming a specific prefix. */ capturingConversationById(id: string): { id: string; startedAtUtc: string; endedAtUtc: string; } | null; upsertSpeaker(input: SpeakerInput): void; /** Read every speaker cluster with decoded centroid + examples (diarization restart seed). */ readSpeakerClusters(): SpeakerClusterRow[]; listSpeakers(): SpeakerRow[]; pendingChunkCount(): number; recordPendingChunk(input: PendingChunkInput): void; listPendingChunks(reason?: PendingChunkReason): PendingChunkRecord[]; deletePendingChunk(id: string): void; stats(): { conversations: number; segments: number; chunks: number; }; getConversation(id: string): DaemonConversation | null; /** * Final conversations whose local day (per `timezone`) equals `date`, * paged by the stable (started_at_utc, id) keyset. Fetches all final * rows after the cursor (the spool is a bounded buffer, not an archive), * filters to the requested local day, then pages — so the id tiebreak * keeps pagination correct across duplicate start timestamps. */ queryFinalConversations(opts: QueryFinalOptions): ConversationPage; } /** * `--replay ` ingestion. Feeds synthetic fixture conversations into * the spool so the entire read path (spool + HTTP API) is testable in CI * without capture hardware or STT. Fixtures are synthetic by policy — no * real audio or conversation data lives in the repo, and none is required * for tests. * * Each `*.json` fixture is either a single conversation object or an array * of them. Every field is validated loudly: an absent optional field takes * its default, but a present-but-wrong-typed/invalid field throws * CaptureConfigError naming the file and path (no silent coercion). A * conversation is fully parsed BEFORE its speakers are upserted, so a * malformed conversation never persists speaker rows. * * { * "id": "conv_demo1", // optional; generated if absent * "startedAtUtc": "2026-07-20T15:00:00.000Z", * "endedAtUtc": "2026-07-20T15:05:00.000Z", // optional * "state": "final", // optional; "final" | "capturing" * "device": "MacBook mic", // optional * "speakers": [ { "id": "spk_1", "label": "Alice", "isSelf": false } ], * "segments": [ * { "speakerCluster": "spk_1", "isWearer": false, "channel": "mic", * "text": "hello there", "startUtc": "...", "endUtc": "..." } * ] * } * * Ingestion is idempotent by conversation id (see Spool.insertConversation), * so re-running a replay is a content no-op. */ interface ReplayResult { files: number; conversationsIngested: number; segmentsIngested: number; ids: string[]; /** True when a cooperative cancel (AbortSignal) stopped ingestion early. */ aborted: boolean; } /** Commit size between event-loop yields in the responsive ingester. */ declare const REPLAY_COMMIT_BATCH = 25; /** Synchronous ingest: validate the whole directory, then commit it all. */ declare function ingestReplayDir(spool: Spool, dir: string): ReplayResult; /** * Responsive ingest: everything is validated up front (atomic — a later * invalid record commits nothing), then committed in bounded batches with an * event-loop yield between them so a co-hosted HTTP server stays responsive * during a large replay. */ declare function ingestReplayDirResponsive(spool: Spool, dir: string, options?: { signal?: AbortSignal; }): Promise; interface TranscribedSegment { text: string; startUtc: string; endUtc: string; } interface WhisperRunResult { code: number; stdout: string; stderr: string; } interface WhisperTranscriptionInput { wavPath: string; modelPath: string; chunkStartedAtUtc: string; threads?: number | null; run: (command: string, args: string[]) => Promise; } declare function parseWhisperJson(output: string, chunkStartedAtUtc: string): TranscribedSegment[]; declare function resolveModelPath(configuredPath: string | undefined, defaultPath: string, exists?: (path: string) => boolean): string; declare function buildWhisperArgs(wavPath: string, modelPath: string, threads?: number | null): string[]; declare function transcribeWithWhisper(input: WhisperTranscriptionInput): Promise; declare function runWhisperCli(command: string, args: string[]): Promise; type ModelFetch = (url: string) => Promise; interface ModelDownloadInput { model: string; directory: string; fetch?: ModelFetch; } interface ModelDownloadResult { path: string; downloaded: boolean; } declare function whisperModelUrl(model: string): string; declare function downloadWhisperModel(input: ModelDownloadInput): Promise; declare function pruneExpiredRawAudio(rawDirectory: string, retentionMs: number, nowMs?: number): Promise; interface SileroVadInput { modelPath: string; minSpeechMs: number; minSilenceMs?: number; maxSpeechMs?: number; threshold?: number; threads?: number; } interface SherpaOnnxModule { Vad: new (config: unknown, bufferSeconds: number) => unknown; } declare function sileroVadConfig(input: SileroVadInput): { config: object; bufferSeconds: number; }; declare function loadSherpaOnnx(importModule?: (specifier: string) => Promise): Promise; declare function createSileroVad(input: SileroVadInput, load?: () => Promise, exists?: (path: string) => boolean): Promise; /** * Loopback-only HTTP daemon. Serves the spool over three read-only routes: * * GET /v1/health → liveness + capture status + instanceId * GET /v1/conversations → final conversations for a local day (keyset paged) * GET /v1/speakers → speaker clusters (curation aid) * * Security: capture-audio serves PLAIN HTTP and has no TLS contract, so it * refuses to bind a non-loopback host — transcript data must never cross * the network in cleartext (a remote reader must front it with their own * TLS/tunnel, out of scope here). Every request MUST carry * `Authorization: Bearer ` matching the daemon token, even on * loopback, so another local user cannot read transcripts off 127.0.0.1. * Input errors are 400; anything unexpected is 500 with no foreign text. */ interface DaemonDeps { spool: Spool; config: DaemonConfig; token: string; /** Live capture status for /v1/health; a getter is re-read per request so it tracks the live runner. */ capturing?: boolean | (() => boolean); } interface DaemonHandle { server: http.Server; host: string; port: number; url: string; close(): Promise; } declare function createRequestHandler(deps: DaemonDeps): http.RequestListener; declare function startDaemon(deps: DaemonDeps): Promise; /** * Daemon process control: an atomic, identity-bearing pid file plus * liveness probing. * * The pid file is JSON `{ pid, instanceId, startedAtIso }` written via a * temp-file + rename so a reader never sees a partial write, and reads are * tolerant of a concurrent delete. `instanceId` (the spool instance id) * lets `stop`/`status` confirm — over the authenticated health endpoint — * that the recorded pid really is our daemon before signalling it, which * guards against PID reuse. Removal is owner-checked so a late shutdown * can't delete a newer daemon's control file. */ interface PidRecord { pid: number; /** Daemon instance id (spool instance_id) for cross-process identity; null when unknown. */ instanceId: string | null; /** ISO timestamp the record was written. */ startedAtIso: string; /** Effective bound host, when known (so status/stop reach the daemon the CLI actually started). */ host: string | null; /** Effective bound port, when known. */ port: number | null; } interface PidWriteOptions { instanceId?: string | null; startedAtIso?: string; host?: string | null; port?: number | null; } /** Atomically write the pid record (temp file + rename) — no partial reads. */ declare function writePidFile(pidPath: string, pid: number, options?: PidWriteOptions): void; /** Read the pid record; a missing file or a partial/concurrent write returns null. */ declare function readPidRecord(pidPath: string): PidRecord | null; /** Convenience accessor: the recorded pid, or null. */ declare function readPidFile(pidPath: string): number | null; /** Liveness via signal 0. ESRCH → gone; EPERM → alive but owned by another user. */ declare function isProcessAlive(pid: number): boolean; /** Remove the pid file unconditionally (stale reclaim). */ declare function removePidFile(pidPath: string): void; /** * Remove the pid file only when it still records `pid`. Prevents a late * shutdown or `stop` from deleting a NEWER daemon's control file after a * restart or PID reuse. */ declare function removePidFileIfOwner(pidPath: string, pid: number): void; /** * `remnic-capture-audio` CLI. Subcommands: init, start, stop, status, * devices, logs. `start --replay ` feeds synthetic fixtures through * the spool + HTTP API (the CI-friendly, hardware-free path). Native * device enumeration and real capture arrive in later checklist items; * `devices` reports that honestly rather than faking a device list. */ interface CliIo { argv: string[]; env?: NodeJS.ProcessEnv; stdout?: (line: string) => void; downloadModel?: (input: ModelDownloadInput) => Promise; stderr?: (line: string) => void; /** * argv tokens (after the node executable) that re-launch THIS CLI, used * when the daemon backgrounds itself into `--foreground`. Defaults to * [process.argv[1]] (direct `remnic-capture-audio` invocation). The * `remnic capture audio` passthrough supplies [remnicBin, "capture", * "audio"] so the detached child is `remnic capture audio start * --foreground`, not `remnic start --foreground`. */ spawnArgvPrefix?: string[]; } /** * Run replay ingestion as a supervised task AFTER the daemon is ready. Never * throws: success/failure is surfaced via the spool's `replay_status` meta * (also exposed on /v1/health) and the daemon log, so a failed or slow replay * never kills the daemon or retracts its readiness. */ declare function superviseReplay(spool: Spool, replayDir: string, io: { stdout: (l: string) => void; stderr: (l: string) => void; }, signal?: AbortSignal): Promise; declare function runCapture(io: CliIo): Promise; /** * `desktop` wearable source connector (issue #1897, component 4). * * À-la-carte optional companion of @remnic/core: installing core alone * never pulls this in; core discovers it at runtime via a * computed-specifier dynamic import (registry entry {id:"desktop", * suffix:"capture-audio"}) or via a direct import of @remnic/capture-audio, * which self-registers idempotently. * * The connector is a pure API client + normalizer over the capture-audio * daemon's loopback HTTP API: no file IO beyond reading the local token, * no memory writes, no pipeline behavior (all of that stays in core so * desktop audio gets the same cleanup/corrections/trust gating as every * other wearable source). * * Token resolution (in order): settings.apiKey (config) -> * REMNIC_CAPTURE_AUDIO_TOKEN env -> the daemon's local token file * (~/.remnic/capture/token) when the base URL is loopback. */ declare const DESKTOP_SOURCE_ID = "desktop"; /** Error raised for a genuine backend failure (never for an empty day). */ declare class DesktopDaemonError extends Error { constructor(message: string); } /** * Resolve the daemon bearer token. The local token file is read ONLY for * a loopback base URL — a remote reader must supply the token explicitly * (config/env), never inherit this machine's local token. */ declare function resolveCaptureAudioToken(configured: string | undefined, baseUrl: string, env?: NodeJS.ProcessEnv): string | undefined; declare function daemonConversationToWearable(conv: DaemonConversation): WearableConversation; declare function createDesktopConnector(options: WearableConnectorFactoryOptions): WearableSourceConnector; declare const wearableConnectorRegistration: WearableConnectorRegistration; /** Idempotently register the desktop connector with the core registry. */ declare function ensureDesktopConnectorRegistered(): boolean; /** * Cross-channel dedup (issue #1897, component 2.4). * * A speakerphone is heard twice: once on the mic and once on the system * (loopback) channel. When the mic and system channels transcribe * near-identical text in overlapping time, keep the SYSTEM copy (the * cleaner far-end signal) and drop the mic copy. Match rule: word-level * Jaccard >= 0.8 within +-5 s. Pure over segment arrays so the pipeline * and the unit tests share one implementation. */ /** Minimum shape needed to dedup; the pipeline's richer segments satisfy it. */ interface DedupSegment { channel: string; text: string; startUtc: string; endUtc: string; } declare function wordJaccard(a: string, b: string): number; /** * Drop mic segments that duplicate a system segment (overlapping time + * Jaccard >= threshold). System segments and non-duplicate mic segments * are preserved in input order. Generic so callers keep their richer type. */ declare function dedupeCrossChannel(segments: readonly T[], options?: { toleranceMs?: number; jaccardThreshold?: number; }): T[]; /** * Conversation assembly (issue #1897, component 2.5). * * A conversation is a maximal run of consecutive speech segments whose * inter-segment gap stays below `conversationGapMinutes`. A gap greater * than OR EQUAL to the threshold starts a new conversation (the join rule * is strictly `gap < threshold`, per the issue). Pure over ordered * segments so the daemon pipeline and unit tests share one implementation; * output rows map 1:1 to Spool.insertConversation input. */ /** A segment as it enters assembly (post dedup + diarization). */ type AssemblySegment = SegmentInput; /** * Group ordered segments into conversations. Segments MUST already be in * chronological order (the pipeline emits them that way). Each returned * row omits `id` so Spool.insertConversation mints a `conv_`. * * `gapMinutes` is the max silence that keeps two segments in the same * conversation; `state` is applied to every produced conversation * (default "final" — the API only serves final; callers pass "capturing" * for the still-open tail). */ declare function assembleConversations(segments: readonly AssemblySegment[], gapMinutes: number, state?: ConversationState): ConversationInput[]; /** Default per issue #1897 config surface. */ declare const DEFAULT_CONVERSATION_GAP_MINUTES = 10; /** A conversation the stateful assembler is building incrementally. */ interface AssembledConversation { id: string; startedAtUtc: string; endedAtUtc: string; state: ConversationState; segments: AssemblySegment[]; } interface AssemblerOptions { gapMinutes?: number; /** Injectable for deterministic ids in tests; defaults to `conv_`. */ makeId?: () => string; } /** * Incremental sibling of `assembleConversations` for the live daemon: feed * segments one chunk at a time and it groups them into conversations under the * same `gap < threshold` rule, tracking a single open (`capturing`) * conversation. The batch function stays the source of truth for replay; this * class owns the streaming case. Pure in-memory — the processor decides when to * persist and provides restart continuity via `resume`. */ declare class ConversationAssembler { #private; constructor(options?: AssemblerOptions); /** * Append one segment, returning the conversation it landed in. Segments * arrive in non-decreasing start order; a gap of at least the threshold * closes the open conversation and starts a new one. */ add(segment: AssemblySegment): AssembledConversation; /** Flip every open (`capturing`) conversation to `final`; returns the count changed. */ finalize(): number; /** * Re-open a conversation recovered from durable storage so a chunk arriving * after a process restart continues it (subject to the same gap rule via * `add`) instead of splitting off a new one. No-op when a conversation is * already open in this run. */ resume(conversation: { id: string; startedAtUtc: string; endedAtUtc: string; }): void; /** * Drop finalized conversations the caller no longer needs. * * A long-running daemon would otherwise retain every conversation and every * segment forever, which makes the rollback snapshot below O(capture * history) and the daemon's per-chunk work quadratic (issue #2145). Only the * open conversation can still be mutated, so nothing else needs keeping. */ pruneFinalized(): number; /** * Deep snapshot for rollback (issue #2145). * * `add` mutates the open conversation in place. A caller that fails BEFORE * anything was persisted must be able to rewind, or the retry feeds earlier * timestamps into an advanced assembler and collapses conversations the * first attempt had split. A caller that already persisted something must * NOT rewind: the durable ids would then diverge from the in-memory ones. */ checkpoint(): AssembledConversation[]; /** Rewind to a {@link checkpoint}. */ rewind(snapshot: readonly AssembledConversation[]): void; /** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */ conversations(): AssembledConversation[]; /** * Finalize the open conversation when `nowUtc` is at least the gap past its * last segment, so a run of silent chunks (which carry no segments to `add`) * still closes a conversation instead of leaving it `capturing` until stop. * Returns the closed conversation's id, or null when nothing closed. */ closeIfIdle(nowUtc: string): string | null; } /** * Speaker diarization clustering (issue #1897, component 2.3). * * The daemon computes one speaker embedding per VAD speech segment (via * the optional sherpa-onnx speaker-id model, wired with the native * capture layer). This module owns the CPU-cheap, hardware-free half: * matching an embedding to a stable speaker cluster and maintaining the * cluster's running centroid + a bounded diverse example set. It is pure * over embedding vectors so the fragmentation regression (one synthetic * voice across many segments -> one cluster) runs in CI without models. * * Match score = best cosine similarity against BOTH the cluster centroid * and up to `maxExamples` stored examples (issue: "take the best score"). */ type Embedding = readonly number[]; interface SpeakerCluster { id: string; centroid: number[]; examples: number[][]; embeddingCount: number; isSelf: boolean; label: string | null; } declare function cosineSimilarity(a: Embedding, b: Embedding): number; /** * Assigns embeddings to stable speaker clusters. Ids are `spk_` (or * `self` for the enrolled wearer). Seed with persisted clusters so ids * survive daemon restarts. */ declare class SpeakerClusterer { #private; constructor(threshold: number, seed?: readonly SpeakerCluster[]); /** Register an enrolled self profile (its embedding seeds the `self` cluster). */ enrollSelf(embedding: Embedding): void; /** Match `embedding` to an existing cluster or create a new `spk_`. */ assign(embedding: Embedding): string; /** * Replace every cluster with `snapshot` (issue #2145). * * `assign` mutates centroids and counts in place, so a diarization commit * that rolls back in SQLite must roll back here too — otherwise the retry * counts the same embeddings twice. Deep-copied, so the caller's snapshot * cannot alias internal state. */ restore(snapshot: readonly SpeakerCluster[]): void; /** Snapshot for persistence. */ clusters(): SpeakerCluster[]; } /** * Chunk processor (issue #1897) — turns completed native WAV chunk events * into durable, replay-safe conversations in the spool. * * The native helper runner owns process lifecycle and emits one validated * `ChunkEvent` per recorded WAV. This module consumes those events through a * single serialized promise chain: resolve model -> transcribe -> normalize * nonempty segments -> assemble -> persist via durable chunk idempotency -> * delete the raw WAV. A rejected chunk is reported and the chain recovers so * the daemon stays alive; the durable `applied_chunks` guard keeps a * restart/replay of the same chunk from duplicating segments. * * Every collaborator (STT, model resolution, raw-audio cleanup) is injected, * so no optional VAD/native runtime is imported here and the package stays * à-la-carte. */ interface ChunkTranscribeInput { wavPath: string; modelPath: string; chunkStartedAtUtc: string; } interface ChunkProcessorDeps { spool: Spool; /** Stateful assembler that groups consecutive segments into conversations. */ assembler: ConversationAssembler; /** Resolve the STT model path; called per speech chunk and may throw when absent. */ resolveModel: () => string; /** Transcribe one WAV chunk into raw segments. */ transcribe: (input: ChunkTranscribeInput) => Promise; /** Delete the raw WAV under retention once the chunk is durably persisted. */ cleanupRawAudio: (event: ChunkEvent) => Promise; /** * VAD speech gate. When provided and it resolves false, the chunk is treated * as non-speech: STT is skipped (the CPU-budget guard) and no segments * persist. Absent -> every chunk is transcribed. */ detectSpeech?: (event: ChunkEvent) => boolean | Promise; /** * Speaker-embedding extractor for diarization. With `diarizer`, each segment * is embedded and assigned to a speaker cluster; absent -> the interim * mic=wearer heuristic and no speaker cluster. */ embed?: (event: ChunkEvent, segment: TranscribedSegment) => Embedding | Promise; /** Speaker clusterer (seeded from the spool); its clusters are persisted on finalize. */ diarizer?: SpeakerClusterer; /** Cross-channel dedup window in ms; defaults to the dedup module's tolerance. */ dedupWindowMs?: number; /** * Bounded reorder window in ms for cross-channel arrival skew (issue * #2145). A transcribed chunk is HELD until the newest observed chunk end * is this far past its own end, then released oldest-first, so a delayed * system chunk is assembled into the conversation it temporally belongs to * rather than joined to a later mic chunk's. 0 (the default here, so * existing callers are unchanged) releases every chunk on arrival. */ reorderWindowMs?: number; /** Reports a per-chunk failure; the chain keeps running afterwards. */ onError?: (error: Error, event: ChunkEvent) => void; } interface ChunkProcessor { /** onChunk seam for `NativeRunnerOptions`. Never throws; failures route to `onError`. */ enqueue(event: ChunkEvent): void; /** Resolve once the serialized chain has settled all enqueued chunks. */ drain(): Promise; /** Drain, then flip open conversations to `final`; returns the count closed. */ finalize(): Promise; } declare function chunkStableId(event: ChunkEvent): string; declare function createChunkProcessor(deps: ChunkProcessorDeps): ChunkProcessor; interface OrphanScanInput { rawDirectory: string; spool: Spool; } /** * Rebuild chunk events from durable pending rows and leftover WAVs so a * restart can feed them through the live processor (issue #2379). */ declare function scanOrphanedChunks(input: OrphanScanInput): ChunkEvent[]; /** * Live capture wiring (issue #1897) — assembles the native helper runner and * the chunk processor into one start/stop unit the daemon drives. * * The native runner owns the helper process and surfaces validated * `ChunkEvent`s; the processor turns each recorded WAV into durable, replay-safe * conversations in the spool. This module wires them with production defaults * (whisper STT, model resolution, raw-audio cleanup) while keeping every * collaborator injectable, so tests drive the whole pipeline against a fake * helper binary and a fake transcriber without any macOS runtime. */ interface LiveCaptureOptions { spool: Spool; config: DaemonConfig; /** Directory the helper writes WAV chunks into (`audio-capture --out`). */ outDir: string; /** Default whisper model path when `config.stt.modelPath` is unset. */ defaultModelPath: string; onError?: (error: Error) => void; onStderr?: (line: string) => void; spawn?: HelperSpawn; resolveBinary?: (deps: ResolveHelperDeps) => HelperResolution; resolution?: HelperResolution; transcribe?: (input: ChunkTranscribeInput) => Promise; resolveModel?: () => string; cleanupRawAudio?: (event: ChunkEvent) => Promise; scheduleRestart?: (fn: () => void, delayMs: number) => RestartTimer; cancelRestart?: (timer: RestartTimer) => void; makeConversationId?: () => string; /** VAD speech gate seam; production supplies a sherpa-onnx detector. */ detectSpeech?: (event: ChunkEvent) => boolean | Promise; /** Speaker-embedding seam for diarization; production supplies sherpa speaker-id. */ embed?: (event: ChunkEvent, segment: TranscribedSegment) => Embedding | Promise; } interface LiveCapture { start(): void; /** Stop the helper, then drain + finalize the processor. */ stop(): Promise; readonly running: boolean; /** Test/observability seam. */ readonly processor: ChunkProcessor; } /** Wire the native runner + chunk processor into one live-capture unit. */ declare function createLiveCapture(options: LiveCaptureOptions): LiveCapture; /** * `install-service` support (issue #1897) — render and install a per-user * background service that runs the capture-audio daemon in live-capture mode. * * macOS uses a launchd LaunchAgent (`~/Library/LaunchAgents/