@remnic/capture-audio 9.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1090 @@
1
+ import http from 'node:http';
2
+ import { WearableConnectorFactoryOptions, WearableSourceConnector, WearableConversation, WearableConnectorRegistration } from '@remnic/core';
3
+
4
+ /** Package-wide constants for @remnic/capture-audio. */
5
+ /**
6
+ * Reported by GET /v1/health. Kept in sync with package.json by the
7
+ * release tooling; the health endpoint tolerates drift because the
8
+ * connector never gates on an exact match (it reads `ok`).
9
+ */
10
+ declare const CAPTURE_AUDIO_VERSION = "9.14.0";
11
+ /** Loopback default; capture is local-first (charter). */
12
+ declare const DEFAULT_HOST = "127.0.0.1";
13
+ declare const DEFAULT_PORT = 4340;
14
+ /** Spool schema version, persisted in the `meta` table. */
15
+ declare const SPOOL_SCHEMA_VERSION = 1;
16
+
17
+ /**
18
+ * Error taxonomy for @remnic/capture-audio.
19
+ *
20
+ * Two authored-message classes, mirroring the wearables split
21
+ * (packages/remnic-core/src/wearables/errors.ts): configuration problems
22
+ * and caller-correctable input. Both carry operator-safe messages (never
23
+ * foreign error text, never credentials). The HTTP layer maps
24
+ * CaptureInputError to 400; anything else is a backend fault (500).
25
+ */
26
+ /** Config load/validation failure — surfaced loudly, never silently defaulted (rule 39). */
27
+ declare class CaptureConfigError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ /** Caller-correctable request/CLI input — maps to HTTP 400. */
31
+ declare class CaptureInputError extends Error {
32
+ constructor(message: string);
33
+ }
34
+
35
+ /**
36
+ * Daemon config (`~/.remnic/capture/audio.json`), created by
37
+ * `remnic-capture-audio init`. Strict and loud: an absent field takes the
38
+ * documented default, but a present-but-invalid value throws
39
+ * CaptureConfigError (rule 39 — no silent defaulting). Ports are integers
40
+ * in [1, 65535] (rule 17); booleans coerce boolean-like strings (rule 24).
41
+ *
42
+ * Only `whisper-cpp` is currently accepted for STT. VAD configuration maps
43
+ * directly to the optional Sherpa Silero runtime adapter.
44
+ */
45
+ interface SttConfig {
46
+ engine: "whisper-cpp";
47
+ modelPath: string | null;
48
+ threads: number | null;
49
+ }
50
+ interface VadConfig {
51
+ modelPath: string | null;
52
+ minSpeechMs: number;
53
+ minSilenceMs: number;
54
+ maxSpeechMs: number;
55
+ threshold: number;
56
+ threads: number;
57
+ }
58
+ interface DiarizationConfig {
59
+ similarityThreshold: number;
60
+ }
61
+ interface DeviceConfig {
62
+ mic: string | null;
63
+ system: string | null;
64
+ }
65
+ interface DaemonConfig {
66
+ host: string;
67
+ port: number;
68
+ chunkSeconds: number;
69
+ captureChannel: "mic" | "system" | "both";
70
+ conversationGapMinutes: number;
71
+ rawRetentionHours: number;
72
+ spoolRetentionDays: number;
73
+ vad: VadConfig;
74
+ diarization: DiarizationConfig;
75
+ stt: SttConfig;
76
+ denyApps: string[];
77
+ devices: DeviceConfig;
78
+ }
79
+ declare function defaultDaemonConfig(): DaemonConfig;
80
+ declare function parseDaemonConfig(raw: unknown): DaemonConfig;
81
+ declare function loadDaemonConfig(configPath: string): DaemonConfig;
82
+ declare function serializeDaemonConfig(cfg: DaemonConfig): string;
83
+
84
+ /** Filesystem layout for the capture working directory. */
85
+ interface CapturePaths {
86
+ baseDir: string;
87
+ configPath: string;
88
+ spoolPath: string;
89
+ tokenPath: string;
90
+ pidPath: string;
91
+ logPath: string;
92
+ }
93
+ /**
94
+ * Root of the capture working directory. `REMNIC_CAPTURE_DIR` overrides
95
+ * the default `~/.remnic/capture` (tests and multi-instance setups point
96
+ * it at a scratch dir). A leading `~` expands to the home directory.
97
+ */
98
+ declare function captureBaseDir(env?: NodeJS.ProcessEnv): string;
99
+ declare function capturePaths(baseDir?: string): CapturePaths;
100
+
101
+ /**
102
+ * Bearer-token lifecycle. The daemon auto-generates a 256-bit token on
103
+ * first use and stores it 0600; a pre-existing file is re-chmod'd 0600
104
+ * defensively because a world-readable token is a credential leak. The
105
+ * token is REQUIRED on every request when the daemon binds a non-loopback
106
+ * host (see daemon.ts); on loopback it exists but localhost is trusted.
107
+ */
108
+ declare function generateToken(): string;
109
+ declare function loadOrCreateToken(tokenPath: string): string;
110
+ /** Constant-time compare; unequal lengths short-circuit to false. */
111
+ declare function tokensMatch(expected: string, presented: string): boolean;
112
+ /** Parse `Authorization: Bearer <token>`; returns null when absent/malformed. */
113
+ declare function bearerFromHeader(header: string | string[] | undefined): string | null;
114
+
115
+ /**
116
+ * Request-input validation for the HTTP surface. Every failure raises
117
+ * CaptureInputError, which the daemon maps to HTTP 400 — invalid date,
118
+ * timezone, limit, or cursor is rejected loudly, never silently defaulted
119
+ * (rule 39). The keyset cursor is an opaque base64url token over the
120
+ * (started_at_utc, id) tuple the conversations query orders by.
121
+ */
122
+ /** Validate a YYYY-MM-DD calendar date (rejects e.g. 2026-02-30). */
123
+ declare function parseTranscriptDate(value: string | null | undefined): string;
124
+ /** Validate an IANA timezone by attempting to build a formatter for it. */
125
+ declare function assertValidTimezone(value: string | null | undefined): string;
126
+ /** Absent limit → default; present-but-invalid → 400. */
127
+ declare function parseLimit(value: string | null | undefined): number;
128
+ interface Cursor {
129
+ startedAtUtc: string;
130
+ id: string;
131
+ }
132
+ declare function encodeCursor(startedAtUtc: string, id: string): string;
133
+ /** Absent cursor → null (first page); malformed cursor → 400. */
134
+ declare function decodeCursor(value: string | null | undefined): Cursor | null;
135
+
136
+ /**
137
+ * SQLite spool — the daemon's local buffer of captured conversations.
138
+ *
139
+ * Uses the built-in `node:sqlite` driver (no native dependency), keeping
140
+ * @remnic/capture-audio à-la-carte: installing it pulls zero extra runtime
141
+ * packages. WAL mode + foreign keys are enabled per connection.
142
+ *
143
+ * Schema (names/semantics fixed by issue #1897):
144
+ * chunks(id, channel, device, started_at_utc, ended_at_utc, status, wav_path)
145
+ * segments(id, chunk_id FK, conversation_id FK, speaker_cluster, is_wearer,
146
+ * channel, text, start_utc, end_utc, ordinal)
147
+ * conversations(id, started_at_utc, ended_at_utc, state, segment_count)
148
+ * speaker_clusters(id, label, centroid, example_embeddings, embedding_count, is_self)
149
+ * meta(key, value)
150
+ *
151
+ * The public read API (`queryFinalConversations`) serves ONLY `final`
152
+ * conversations, ordered by a stable keyset (started_at_utc, id) so the
153
+ * connector never ingests half a meeting and pagination is deterministic
154
+ * even when two conversations share a start timestamp.
155
+ */
156
+ type ConversationState = "capturing" | "final";
157
+ type ChunkStatus = "pending" | "transcribed" | "failed" | "deleted";
158
+ interface SegmentInput {
159
+ speakerCluster?: string | null;
160
+ isWearer?: boolean;
161
+ channel: string;
162
+ text: string;
163
+ startUtc: string;
164
+ endUtc: string;
165
+ }
166
+ interface ConversationInput {
167
+ id?: string;
168
+ startedAtUtc: string;
169
+ endedAtUtc?: string | null;
170
+ state?: ConversationState;
171
+ device?: string | null;
172
+ chunkStatus?: ChunkStatus;
173
+ wavPath?: string | null;
174
+ segments: SegmentInput[];
175
+ }
176
+ interface SpeakerInput {
177
+ id: string;
178
+ label?: string | null;
179
+ isSelf?: boolean;
180
+ embeddingCount?: number;
181
+ /** Speaker embedding centroid; persisted as a JSON BLOB for restart-stable ids. */
182
+ centroid?: readonly number[] | null;
183
+ /** Bounded diverse example embeddings; persisted as a JSON BLOB. */
184
+ examples?: readonly (readonly number[])[] | null;
185
+ }
186
+ interface SpeakerClusterRow {
187
+ id: string;
188
+ label: string | null;
189
+ isSelf: boolean;
190
+ embeddingCount: number;
191
+ centroid: number[];
192
+ examples: number[][];
193
+ }
194
+ interface DaemonSegment {
195
+ textRaw: string;
196
+ speakerKey: string | null;
197
+ isWearer: boolean;
198
+ channel: string;
199
+ startUtc: string;
200
+ endUtc: string;
201
+ }
202
+ interface DaemonConversation {
203
+ id: string;
204
+ startedAtUtc: string;
205
+ endedAtUtc: string | null;
206
+ state: ConversationState;
207
+ segmentCount: number;
208
+ segments: DaemonSegment[];
209
+ }
210
+ interface ConversationPage {
211
+ conversations: DaemonConversation[];
212
+ nextCursor: string | null;
213
+ }
214
+ interface SpeakerRow {
215
+ id: string;
216
+ label: string | null;
217
+ isSelf: boolean;
218
+ embeddingCount: number;
219
+ }
220
+ interface QueryFinalOptions {
221
+ date: string;
222
+ timezone: string;
223
+ cursor?: string | null;
224
+ limit: number;
225
+ }
226
+ interface AssemblyAppendInput {
227
+ /** Durable dedup marker for one application (e.g. a transcribed chunk id). */
228
+ idempotencyKey: string;
229
+ /** Stable `conv_<ulid>` id from the assembler. */
230
+ conversationId: string;
231
+ /** Conversation start; used only when the conversation is first created. */
232
+ startedAtUtc: string;
233
+ /** Defaults to `capturing`; the conversation is finalized by a later call. */
234
+ state?: ConversationState;
235
+ device?: string | null;
236
+ /** Backing chunk row id; defaults to `idempotencyKey`. */
237
+ chunkId?: string;
238
+ /** Backing WAV path recorded on the chunk row; retained for the janitor/audit. */
239
+ wavPath?: string | null;
240
+ segments: SegmentInput[];
241
+ }
242
+ interface AssemblyAppendResult {
243
+ /** False when `idempotencyKey` was already applied (a replay no-op). */
244
+ applied: boolean;
245
+ conversationId: string;
246
+ /** Total segments in the conversation after this call. */
247
+ segmentCount: number;
248
+ }
249
+ declare class Spool {
250
+ #private;
251
+ constructor(location: string);
252
+ close(): void;
253
+ meta(key: string): string | null;
254
+ setMeta(key: string, value: string): void;
255
+ /**
256
+ * Insert (or replace) a whole conversation with its segments and a
257
+ * backing chunk row, atomically. Idempotent by conversation id:
258
+ * re-ingesting the same id deletes the prior rows first, so a repeated
259
+ * replay is a content no-op (kill-9 restart safety, acceptance criteria).
260
+ */
261
+ insertConversation(input: ConversationInput): string;
262
+ /** Flip every still-open conversation to `final` (daemon stop / gap timeout). */
263
+ finalizeOpenConversations(): number;
264
+ /**
265
+ * Durably append one transcribed chunk's segments to a conversation,
266
+ * idempotent on `idempotencyKey` (a replay/restart of the same chunk is a
267
+ * no-op). Creates the conversation as `capturing` on first append; a later
268
+ * `finalizeConversation`/`finalizeOpenConversations` flips it to `final`.
269
+ */
270
+ appendAssembledSegments(input: AssemblyAppendInput): AssemblyAppendResult;
271
+ /** Flip one conversation to `final`; returns true when it was capturing. */
272
+ finalizeConversation(id: string): boolean;
273
+ /**
274
+ * A conversation's segments in the shape cross-channel dedup needs (segment
275
+ * id + DedupSegment fields), chronological. Used to prune loopback duplicates
276
+ * at finalization, which is order-independent (all segments are present).
277
+ */
278
+ conversationSegmentsForDedup(conversationId: string): Array<{
279
+ id: string;
280
+ channel: string;
281
+ text: string;
282
+ startUtc: string;
283
+ endUtc: string;
284
+ }>;
285
+ /**
286
+ * Delete specific segments (dedup prune), keeping each owning conversation's
287
+ * segment_count in sync. Returns the number actually removed.
288
+ */
289
+ deleteSegments(ids: readonly string[]): number;
290
+ /** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */
291
+ capturingConversationIds(): string[];
292
+ /** Whether a chunk with this idempotency key was already durably applied. */
293
+ isChunkApplied(idempotencyKey: string): boolean;
294
+ /**
295
+ * Record that a whole chunk finished (every group appended) via a `<id>:done`
296
+ * marker, so a later full replay can skip transcription + diarization. A crash
297
+ * before this leaves no marker, so the missing groups re-append on replay.
298
+ */
299
+ markChunkComplete(chunkId: string, conversationId: string): void;
300
+ /**
301
+ * The newest still-`capturing` conversation, so a chunk arriving after a
302
+ * process restart continues it (subject to the assembler's gap rule) instead
303
+ * of splitting off a new one. Null when none is open.
304
+ */
305
+ latestCapturingConversation(): {
306
+ id: string;
307
+ startedAtUtc: string;
308
+ endedAtUtc: string;
309
+ } | null;
310
+ upsertSpeaker(input: SpeakerInput): void;
311
+ /** Read every speaker cluster with decoded centroid + examples (diarization restart seed). */
312
+ readSpeakerClusters(): SpeakerClusterRow[];
313
+ listSpeakers(): SpeakerRow[];
314
+ pendingChunkCount(): number;
315
+ stats(): {
316
+ conversations: number;
317
+ segments: number;
318
+ chunks: number;
319
+ };
320
+ getConversation(id: string): DaemonConversation | null;
321
+ /**
322
+ * Final conversations whose local day (per `timezone`) equals `date`,
323
+ * paged by the stable (started_at_utc, id) keyset. Fetches all final
324
+ * rows after the cursor (the spool is a bounded buffer, not an archive),
325
+ * filters to the requested local day, then pages — so the id tiebreak
326
+ * keeps pagination correct across duplicate start timestamps.
327
+ */
328
+ queryFinalConversations(opts: QueryFinalOptions): ConversationPage;
329
+ }
330
+
331
+ /**
332
+ * `--replay <dir>` ingestion. Feeds synthetic fixture conversations into
333
+ * the spool so the entire read path (spool + HTTP API) is testable in CI
334
+ * without capture hardware or STT. Fixtures are synthetic by policy — no
335
+ * real audio or conversation data lives in the repo, and none is required
336
+ * for tests.
337
+ *
338
+ * Each `*.json` fixture is either a single conversation object or an array
339
+ * of them. Every field is validated loudly: an absent optional field takes
340
+ * its default, but a present-but-wrong-typed/invalid field throws
341
+ * CaptureConfigError naming the file and path (no silent coercion). A
342
+ * conversation is fully parsed BEFORE its speakers are upserted, so a
343
+ * malformed conversation never persists speaker rows.
344
+ *
345
+ * {
346
+ * "id": "conv_demo1", // optional; generated if absent
347
+ * "startedAtUtc": "2026-07-20T15:00:00.000Z",
348
+ * "endedAtUtc": "2026-07-20T15:05:00.000Z", // optional
349
+ * "state": "final", // optional; "final" | "capturing"
350
+ * "device": "MacBook mic", // optional
351
+ * "speakers": [ { "id": "spk_1", "label": "Alice", "isSelf": false } ],
352
+ * "segments": [
353
+ * { "speakerCluster": "spk_1", "isWearer": false, "channel": "mic",
354
+ * "text": "hello there", "startUtc": "...", "endUtc": "..." }
355
+ * ]
356
+ * }
357
+ *
358
+ * Ingestion is idempotent by conversation id (see Spool.insertConversation),
359
+ * so re-running a replay is a content no-op.
360
+ */
361
+
362
+ interface ReplayResult {
363
+ files: number;
364
+ conversationsIngested: number;
365
+ segmentsIngested: number;
366
+ ids: string[];
367
+ /** True when a cooperative cancel (AbortSignal) stopped ingestion early. */
368
+ aborted: boolean;
369
+ }
370
+ /** Commit size between event-loop yields in the responsive ingester. */
371
+ declare const REPLAY_COMMIT_BATCH = 25;
372
+ /** Synchronous ingest: validate the whole directory, then commit it all. */
373
+ declare function ingestReplayDir(spool: Spool, dir: string): ReplayResult;
374
+ /**
375
+ * Responsive ingest: everything is validated up front (atomic — a later
376
+ * invalid record commits nothing), then committed in bounded batches with an
377
+ * event-loop yield between them so a co-hosted HTTP server stays responsive
378
+ * during a large replay.
379
+ */
380
+ declare function ingestReplayDirResponsive(spool: Spool, dir: string, options?: {
381
+ signal?: AbortSignal;
382
+ }): Promise<ReplayResult>;
383
+
384
+ interface TranscribedSegment {
385
+ text: string;
386
+ startUtc: string;
387
+ endUtc: string;
388
+ }
389
+ interface WhisperRunResult {
390
+ code: number;
391
+ stdout: string;
392
+ stderr: string;
393
+ }
394
+ interface WhisperTranscriptionInput {
395
+ wavPath: string;
396
+ modelPath: string;
397
+ chunkStartedAtUtc: string;
398
+ threads?: number | null;
399
+ run: (command: string, args: string[]) => Promise<WhisperRunResult>;
400
+ }
401
+ declare function parseWhisperJson(output: string, chunkStartedAtUtc: string): TranscribedSegment[];
402
+ declare function resolveModelPath(configuredPath: string | undefined, defaultPath: string, exists?: (path: string) => boolean): string;
403
+ declare function buildWhisperArgs(wavPath: string, modelPath: string, threads?: number | null): string[];
404
+ declare function transcribeWithWhisper(input: WhisperTranscriptionInput): Promise<TranscribedSegment[]>;
405
+ declare function runWhisperCli(command: string, args: string[]): Promise<WhisperRunResult>;
406
+
407
+ type ModelFetch = (url: string) => Promise<Response>;
408
+ interface ModelDownloadInput {
409
+ model: string;
410
+ directory: string;
411
+ fetch?: ModelFetch;
412
+ }
413
+ interface ModelDownloadResult {
414
+ path: string;
415
+ downloaded: boolean;
416
+ }
417
+ declare function whisperModelUrl(model: string): string;
418
+ declare function downloadWhisperModel(input: ModelDownloadInput): Promise<ModelDownloadResult>;
419
+
420
+ declare function pruneExpiredRawAudio(rawDirectory: string, retentionMs: number, nowMs?: number): Promise<string[]>;
421
+
422
+ interface SileroVadInput {
423
+ modelPath: string;
424
+ minSpeechMs: number;
425
+ minSilenceMs?: number;
426
+ maxSpeechMs?: number;
427
+ threshold?: number;
428
+ threads?: number;
429
+ }
430
+ interface SherpaOnnxModule {
431
+ Vad: new (config: unknown, bufferSeconds: number) => unknown;
432
+ }
433
+ declare function sileroVadConfig(input: SileroVadInput): {
434
+ config: object;
435
+ bufferSeconds: number;
436
+ };
437
+ declare function loadSherpaOnnx(importModule?: (specifier: string) => Promise<unknown>): Promise<SherpaOnnxModule>;
438
+ declare function createSileroVad(input: SileroVadInput, load?: () => Promise<SherpaOnnxModule>, exists?: (path: string) => boolean): Promise<unknown>;
439
+
440
+ /**
441
+ * Loopback-only HTTP daemon. Serves the spool over three read-only routes:
442
+ *
443
+ * GET /v1/health → liveness + capture status + instanceId
444
+ * GET /v1/conversations → final conversations for a local day (keyset paged)
445
+ * GET /v1/speakers → speaker clusters (curation aid)
446
+ *
447
+ * Security: capture-audio serves PLAIN HTTP and has no TLS contract, so it
448
+ * refuses to bind a non-loopback host — transcript data must never cross
449
+ * the network in cleartext (a remote reader must front it with their own
450
+ * TLS/tunnel, out of scope here). Every request MUST carry
451
+ * `Authorization: Bearer <token>` matching the daemon token, even on
452
+ * loopback, so another local user cannot read transcripts off 127.0.0.1.
453
+ * Input errors are 400; anything unexpected is 500 with no foreign text.
454
+ */
455
+
456
+ interface DaemonDeps {
457
+ spool: Spool;
458
+ config: DaemonConfig;
459
+ token: string;
460
+ /** Live capture status for /v1/health; a getter is re-read per request so it tracks the live runner. */
461
+ capturing?: boolean | (() => boolean);
462
+ }
463
+ interface DaemonHandle {
464
+ server: http.Server;
465
+ host: string;
466
+ port: number;
467
+ url: string;
468
+ close(): Promise<void>;
469
+ }
470
+ declare function createRequestHandler(deps: DaemonDeps): http.RequestListener;
471
+ declare function startDaemon(deps: DaemonDeps): Promise<DaemonHandle>;
472
+
473
+ /**
474
+ * Daemon process control: an atomic, identity-bearing pid file plus
475
+ * liveness probing.
476
+ *
477
+ * The pid file is JSON `{ pid, instanceId, startedAtIso }` written via a
478
+ * temp-file + rename so a reader never sees a partial write, and reads are
479
+ * tolerant of a concurrent delete. `instanceId` (the spool instance id)
480
+ * lets `stop`/`status` confirm — over the authenticated health endpoint —
481
+ * that the recorded pid really is our daemon before signalling it, which
482
+ * guards against PID reuse. Removal is owner-checked so a late shutdown
483
+ * can't delete a newer daemon's control file.
484
+ */
485
+ interface PidRecord {
486
+ pid: number;
487
+ /** Daemon instance id (spool instance_id) for cross-process identity; null when unknown. */
488
+ instanceId: string | null;
489
+ /** ISO timestamp the record was written. */
490
+ startedAtIso: string;
491
+ /** Effective bound host, when known (so status/stop reach the daemon the CLI actually started). */
492
+ host: string | null;
493
+ /** Effective bound port, when known. */
494
+ port: number | null;
495
+ }
496
+ interface PidWriteOptions {
497
+ instanceId?: string | null;
498
+ startedAtIso?: string;
499
+ host?: string | null;
500
+ port?: number | null;
501
+ }
502
+ /** Atomically write the pid record (temp file + rename) — no partial reads. */
503
+ declare function writePidFile(pidPath: string, pid: number, options?: PidWriteOptions): void;
504
+ /** Read the pid record; a missing file or a partial/concurrent write returns null. */
505
+ declare function readPidRecord(pidPath: string): PidRecord | null;
506
+ /** Convenience accessor: the recorded pid, or null. */
507
+ declare function readPidFile(pidPath: string): number | null;
508
+ /** Liveness via signal 0. ESRCH → gone; EPERM → alive but owned by another user. */
509
+ declare function isProcessAlive(pid: number): boolean;
510
+ /** Remove the pid file unconditionally (stale reclaim). */
511
+ declare function removePidFile(pidPath: string): void;
512
+ /**
513
+ * Remove the pid file only when it still records `pid`. Prevents a late
514
+ * shutdown or `stop` from deleting a NEWER daemon's control file after a
515
+ * restart or PID reuse.
516
+ */
517
+ declare function removePidFileIfOwner(pidPath: string, pid: number): void;
518
+
519
+ /**
520
+ * `remnic-capture-audio` CLI. Subcommands: init, start, stop, status,
521
+ * devices, logs. `start --replay <dir>` feeds synthetic fixtures through
522
+ * the spool + HTTP API (the CI-friendly, hardware-free path). Native
523
+ * device enumeration and real capture arrive in later checklist items;
524
+ * `devices` reports that honestly rather than faking a device list.
525
+ */
526
+
527
+ interface CliIo {
528
+ argv: string[];
529
+ env?: NodeJS.ProcessEnv;
530
+ stdout?: (line: string) => void;
531
+ downloadModel?: (input: ModelDownloadInput) => Promise<ModelDownloadResult>;
532
+ stderr?: (line: string) => void;
533
+ /**
534
+ * argv tokens (after the node executable) that re-launch THIS CLI, used
535
+ * when the daemon backgrounds itself into `--foreground`. Defaults to
536
+ * [process.argv[1]] (direct `remnic-capture-audio` invocation). The
537
+ * `remnic capture audio` passthrough supplies [remnicBin, "capture",
538
+ * "audio"] so the detached child is `remnic capture audio start
539
+ * --foreground`, not `remnic start --foreground`.
540
+ */
541
+ spawnArgvPrefix?: string[];
542
+ }
543
+ /**
544
+ * Run replay ingestion as a supervised task AFTER the daemon is ready. Never
545
+ * throws: success/failure is surfaced via the spool's `replay_status` meta
546
+ * (also exposed on /v1/health) and the daemon log, so a failed or slow replay
547
+ * never kills the daemon or retracts its readiness.
548
+ */
549
+ declare function superviseReplay(spool: Spool, replayDir: string, io: {
550
+ stdout: (l: string) => void;
551
+ stderr: (l: string) => void;
552
+ }, signal?: AbortSignal): Promise<void>;
553
+ declare function runCapture(io: CliIo): Promise<number>;
554
+
555
+ /**
556
+ * `desktop` wearable source connector (issue #1897, component 4).
557
+ *
558
+ * À-la-carte optional companion of @remnic/core: installing core alone
559
+ * never pulls this in; core discovers it at runtime via a
560
+ * computed-specifier dynamic import (registry entry {id:"desktop",
561
+ * suffix:"capture-audio"}) or via a direct import of @remnic/capture-audio,
562
+ * which self-registers idempotently.
563
+ *
564
+ * The connector is a pure API client + normalizer over the capture-audio
565
+ * daemon's loopback HTTP API: no file IO beyond reading the local token,
566
+ * no memory writes, no pipeline behavior (all of that stays in core so
567
+ * desktop audio gets the same cleanup/corrections/trust gating as every
568
+ * other wearable source).
569
+ *
570
+ * Token resolution (in order): settings.apiKey (config) ->
571
+ * REMNIC_CAPTURE_AUDIO_TOKEN env -> the daemon's local token file
572
+ * (~/.remnic/capture/token) when the base URL is loopback.
573
+ */
574
+
575
+ declare const DESKTOP_SOURCE_ID = "desktop";
576
+ /** Error raised for a genuine backend failure (never for an empty day). */
577
+ declare class DesktopDaemonError extends Error {
578
+ constructor(message: string);
579
+ }
580
+ /**
581
+ * Resolve the daemon bearer token. The local token file is read ONLY for
582
+ * a loopback base URL — a remote reader must supply the token explicitly
583
+ * (config/env), never inherit this machine's local token.
584
+ */
585
+ declare function resolveCaptureAudioToken(configured: string | undefined, baseUrl: string, env?: NodeJS.ProcessEnv): string | undefined;
586
+ declare function daemonConversationToWearable(conv: DaemonConversation): WearableConversation;
587
+ declare function createDesktopConnector(options: WearableConnectorFactoryOptions): WearableSourceConnector;
588
+ declare const wearableConnectorRegistration: WearableConnectorRegistration;
589
+ /** Idempotently register the desktop connector with the core registry. */
590
+ declare function ensureDesktopConnectorRegistered(): boolean;
591
+
592
+ /**
593
+ * Cross-channel dedup (issue #1897, component 2.4).
594
+ *
595
+ * A speakerphone is heard twice: once on the mic and once on the system
596
+ * (loopback) channel. When the mic and system channels transcribe
597
+ * near-identical text in overlapping time, keep the SYSTEM copy (the
598
+ * cleaner far-end signal) and drop the mic copy. Match rule: word-level
599
+ * Jaccard >= 0.8 within +-5 s. Pure over segment arrays so the pipeline
600
+ * and the unit tests share one implementation.
601
+ */
602
+ /** Minimum shape needed to dedup; the pipeline's richer segments satisfy it. */
603
+ interface DedupSegment {
604
+ channel: string;
605
+ text: string;
606
+ startUtc: string;
607
+ endUtc: string;
608
+ }
609
+ declare function wordJaccard(a: string, b: string): number;
610
+ /**
611
+ * Drop mic segments that duplicate a system segment (overlapping time +
612
+ * Jaccard >= threshold). System segments and non-duplicate mic segments
613
+ * are preserved in input order. Generic so callers keep their richer type.
614
+ */
615
+ declare function dedupeCrossChannel<T extends DedupSegment>(segments: readonly T[], options?: {
616
+ toleranceMs?: number;
617
+ jaccardThreshold?: number;
618
+ }): T[];
619
+
620
+ /**
621
+ * Conversation assembly (issue #1897, component 2.5).
622
+ *
623
+ * A conversation is a maximal run of consecutive speech segments whose
624
+ * inter-segment gap stays below `conversationGapMinutes`. A gap greater
625
+ * than OR EQUAL to the threshold starts a new conversation (the join rule
626
+ * is strictly `gap < threshold`, per the issue). Pure over ordered
627
+ * segments so the daemon pipeline and unit tests share one implementation;
628
+ * output rows map 1:1 to Spool.insertConversation input.
629
+ */
630
+
631
+ /** A segment as it enters assembly (post dedup + diarization). */
632
+ type AssemblySegment = SegmentInput;
633
+ /**
634
+ * Group ordered segments into conversations. Segments MUST already be in
635
+ * chronological order (the pipeline emits them that way). Each returned
636
+ * row omits `id` so Spool.insertConversation mints a `conv_<ulid>`.
637
+ *
638
+ * `gapMinutes` is the max silence that keeps two segments in the same
639
+ * conversation; `state` is applied to every produced conversation
640
+ * (default "final" — the API only serves final; callers pass "capturing"
641
+ * for the still-open tail).
642
+ */
643
+ declare function assembleConversations(segments: readonly AssemblySegment[], gapMinutes: number, state?: ConversationState): ConversationInput[];
644
+ /** Default per issue #1897 config surface. */
645
+ declare const DEFAULT_CONVERSATION_GAP_MINUTES = 10;
646
+ /** A conversation the stateful assembler is building incrementally. */
647
+ interface AssembledConversation {
648
+ id: string;
649
+ startedAtUtc: string;
650
+ endedAtUtc: string;
651
+ state: ConversationState;
652
+ segments: AssemblySegment[];
653
+ }
654
+ interface AssemblerOptions {
655
+ gapMinutes?: number;
656
+ /** Injectable for deterministic ids in tests; defaults to `conv_<ulid>`. */
657
+ makeId?: () => string;
658
+ }
659
+ /**
660
+ * Incremental sibling of `assembleConversations` for the live daemon: feed
661
+ * segments one chunk at a time and it groups them into conversations under the
662
+ * same `gap < threshold` rule, tracking a single open (`capturing`)
663
+ * conversation. The batch function stays the source of truth for replay; this
664
+ * class owns the streaming case. Pure in-memory — the processor decides when to
665
+ * persist and provides restart continuity via `resume`.
666
+ */
667
+ declare class ConversationAssembler {
668
+ #private;
669
+ constructor(options?: AssemblerOptions);
670
+ /**
671
+ * Append one segment, returning the conversation it landed in. Segments
672
+ * arrive in non-decreasing start order; a gap of at least the threshold
673
+ * closes the open conversation and starts a new one.
674
+ */
675
+ add(segment: AssemblySegment): AssembledConversation;
676
+ /** Flip every open (`capturing`) conversation to `final`; returns the count changed. */
677
+ finalize(): number;
678
+ /**
679
+ * Re-open a conversation recovered from durable storage so a chunk arriving
680
+ * after a process restart continues it (subject to the same gap rule via
681
+ * `add`) instead of splitting off a new one. No-op when a conversation is
682
+ * already open in this run.
683
+ */
684
+ resume(conversation: {
685
+ id: string;
686
+ startedAtUtc: string;
687
+ endedAtUtc: string;
688
+ }): void;
689
+ /** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */
690
+ conversations(): AssembledConversation[];
691
+ /**
692
+ * Finalize the open conversation when `nowUtc` is at least the gap past its
693
+ * last segment, so a run of silent chunks (which carry no segments to `add`)
694
+ * still closes a conversation instead of leaving it `capturing` until stop.
695
+ * Returns the closed conversation's id, or null when nothing closed.
696
+ */
697
+ closeIfIdle(nowUtc: string): string | null;
698
+ }
699
+
700
+ /**
701
+ * Speaker diarization clustering (issue #1897, component 2.3).
702
+ *
703
+ * The daemon computes one speaker embedding per VAD speech segment (via
704
+ * the optional sherpa-onnx speaker-id model, wired with the native
705
+ * capture layer). This module owns the CPU-cheap, hardware-free half:
706
+ * matching an embedding to a stable speaker cluster and maintaining the
707
+ * cluster's running centroid + a bounded diverse example set. It is pure
708
+ * over embedding vectors so the fragmentation regression (one synthetic
709
+ * voice across many segments -> one cluster) runs in CI without models.
710
+ *
711
+ * Match score = best cosine similarity against BOTH the cluster centroid
712
+ * and up to `maxExamples` stored examples (issue: "take the best score").
713
+ */
714
+ type Embedding = readonly number[];
715
+ interface SpeakerCluster {
716
+ id: string;
717
+ centroid: number[];
718
+ examples: number[][];
719
+ embeddingCount: number;
720
+ isSelf: boolean;
721
+ label: string | null;
722
+ }
723
+ declare function cosineSimilarity(a: Embedding, b: Embedding): number;
724
+ /**
725
+ * Assigns embeddings to stable speaker clusters. Ids are `spk_<n>` (or
726
+ * `self` for the enrolled wearer). Seed with persisted clusters so ids
727
+ * survive daemon restarts.
728
+ */
729
+ declare class SpeakerClusterer {
730
+ #private;
731
+ constructor(threshold: number, seed?: readonly SpeakerCluster[]);
732
+ /** Register an enrolled self profile (its embedding seeds the `self` cluster). */
733
+ enrollSelf(embedding: Embedding): void;
734
+ /** Match `embedding` to an existing cluster or create a new `spk_<n>`. */
735
+ assign(embedding: Embedding): string;
736
+ /** Snapshot for persistence. */
737
+ clusters(): SpeakerCluster[];
738
+ }
739
+
740
+ /**
741
+ * Native capture helper resolver + supervised process runner (issue #1897,
742
+ * "audio native macOS helper" slice — Node side only).
743
+ *
744
+ * The native recorder is the ONE shared macOS helper shipped by #2138
745
+ * (`remnic-capture-helper`), driven here through its `audio-capture`
746
+ * subcommand. It emits one JSONL `ChunkEvent` per recorded WAV chunk on
747
+ * stdout. This module is deliberately à-la-carte, mirroring the VAD/STT
748
+ * adapters and the screen daemon's helper seam:
749
+ *
750
+ * - The helper ships as an OPTIONAL, per-platform package
751
+ * (`@remnic/capture-native-darwin-arm64` / `-x64`) that exports a
752
+ * `helperBinaryPath` and declares the same binary under `bin`. It is a
753
+ * peer dependency, never a runtime dependency, so `@remnic/capture-audio`
754
+ * installs and works on any platform without it.
755
+ * - The package specifier is COMPUTED from `process.platform`/`arch` so a
756
+ * static importer never bundles a foreign-arch binary, and resolution uses
757
+ * Node module resolution (`require.resolve`).
758
+ * - `REMNIC_CAPTURE_HELPER_BIN` overrides resolution with an explicit binary
759
+ * path (manual installs and the hardware-free test seam, which points it at
760
+ * a fake script emitting canned JSON).
761
+ * - A missing optional package reports the EXACT install command instead of a
762
+ * raw resolver error.
763
+ *
764
+ * The runner is the sole owner of the child process: it spawns the helper,
765
+ * parses stdout strictly line-by-line, reports validated events to a callback,
766
+ * reports stderr/errors separately, and restarts only UNEXPECTED exits with
767
+ * bounded exponential backoff. It never writes the Spool and never invents a
768
+ * conversation — the processing/assembly layer owns eventual Spool writes
769
+ * downstream of the validated events this runner surfaces.
770
+ */
771
+ /** One recorded audio chunk, as emitted by the native helper on stdout (JSONL). */
772
+ interface ChunkEvent {
773
+ path: string;
774
+ channel: "mic" | "system";
775
+ startedAtUtc: string;
776
+ endedAtUtc: string;
777
+ device: string | null;
778
+ }
779
+ /** Which channels the `audio-capture` subcommand records. */
780
+ type ChannelSelection = "mic" | "system" | "both";
781
+ /** A resolved native helper: its source specifier and the on-disk binary path. */
782
+ interface HelperResolution {
783
+ specifier: string;
784
+ binaryPath: string;
785
+ }
786
+ /** The narrow child-process surface the runner depends on (injectable for tests). */
787
+ interface HelperChild {
788
+ stdout: {
789
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
790
+ };
791
+ stderr: {
792
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
793
+ };
794
+ once(event: "error", listener: (err: Error) => void): unknown;
795
+ once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
796
+ kill(signal?: NodeJS.Signals): boolean;
797
+ readonly killed?: boolean;
798
+ readonly pid?: number;
799
+ }
800
+ /** Spawns the helper binary. Defaults to a `node:child_process` adapter. */
801
+ type HelperSpawn = (binaryPath: string, args: string[]) => HelperChild;
802
+ /** An opaque restart-timer token returned by `scheduleRestart`. */
803
+ type RestartTimer = unknown;
804
+ interface ResolveHelperDeps {
805
+ platform?: NodeJS.Platform;
806
+ arch?: string;
807
+ /** `require.resolve`-style resolver; defaults to this module's require. */
808
+ resolve?: (specifier: string) => string;
809
+ readFile?: (file: string) => string;
810
+ /** Environment source for the `REMNIC_CAPTURE_HELPER_BIN` override. */
811
+ env?: NodeJS.ProcessEnv;
812
+ }
813
+ interface NativeRunnerOptions {
814
+ /** Directory the helper writes WAV chunks into (`audio-capture --out`). */
815
+ outDir: string;
816
+ chunkSeconds: number;
817
+ /** Channels to record; defaults to "both". */
818
+ channel?: ChannelSelection;
819
+ /** Optional CoreAudio microphone device UID (`--device`). */
820
+ device?: string | null;
821
+ /** Called once per validated ChunkEvent. */
822
+ onChunk: (event: ChunkEvent) => void;
823
+ /** Called for a rejected stdout line or a spawn/child error. */
824
+ onError?: (error: Error) => void;
825
+ /** Called once per complete stderr line. */
826
+ onStderr?: (line: string) => void;
827
+ /** Pre-resolved helper; when absent the runner resolves it lazily on `start()`. */
828
+ resolution?: HelperResolution;
829
+ resolveBinary?: (deps: ResolveHelperDeps) => HelperResolution;
830
+ spawn?: HelperSpawn;
831
+ /** Max consecutive unexpected restarts before giving up (default 5). */
832
+ maxRestarts?: number;
833
+ /** First backoff delay in ms (default 500). */
834
+ baseBackoffMs?: number;
835
+ /** Backoff ceiling in ms (default 30000). */
836
+ maxBackoffMs?: number;
837
+ scheduleRestart?: (fn: () => void, delayMs: number) => RestartTimer;
838
+ cancelRestart?: (timer: RestartTimer) => void;
839
+ }
840
+ /** A running native-capture supervisor. */
841
+ interface NativeCaptureRunner {
842
+ start(): void;
843
+ /** Stop the helper (SIGTERM) and resolve once it exits and its final chunk is read. */
844
+ stop(): Promise<void>;
845
+ /** True between a `start()` and its matching `stop()`. */
846
+ readonly running: boolean;
847
+ }
848
+ /** The env var that overrides package resolution with an explicit binary path. */
849
+ declare const HELPER_BIN_ENV = "REMNIC_CAPTURE_HELPER_BIN";
850
+ /**
851
+ * Compute the optional native-helper package specifier for a platform/arch.
852
+ * The helper is macOS-only and hardware-gated; every other platform (and any
853
+ * unsupported macOS architecture) throws loudly rather than resolving to a
854
+ * package that cannot exist.
855
+ */
856
+ declare function helperPackageSpecifier(platform: NodeJS.Platform | string, arch: string): string;
857
+ /**
858
+ * Resolve the native helper binary. Order: explicit `REMNIC_CAPTURE_HELPER_BIN`
859
+ * override, then the computed platform package's declared executable (resolved
860
+ * via Node module resolution, identical to its `helperBinaryPath` export).
861
+ * Throws a CaptureConfigError naming the exact install command when the
862
+ * optional package is not installed.
863
+ */
864
+ declare function resolveHelperBinary(deps?: ResolveHelperDeps): HelperResolution;
865
+ /** Build the `audio-capture` argv from runner options (#2138 helper contract). */
866
+ declare function buildHelperArgs(opts: Pick<NativeRunnerOptions, "outDir" | "chunkSeconds" | "channel" | "device">): string[];
867
+ /** Parse and validate one JSONL line into a ChunkEvent; throws on anything malformed. */
868
+ declare function parseChunkEvent(line: string): ChunkEvent;
869
+ /**
870
+ * Run the helper's one-shot `device-enumerate` subcommand and return the parsed
871
+ * device list. Bounded, argv-only, and injectable for tests. Throws a
872
+ * CaptureInputError on a nonzero exit, empty output, or invalid JSON.
873
+ */
874
+ declare function enumerateDevices(binaryPath: string, spawn?: HelperSpawn, timeoutMs?: number): Promise<unknown[]>;
875
+ /**
876
+ * Create a supervised native-capture runner. Dependency-injectable: pass
877
+ * `spawn`, `resolution`/`resolveBinary`, and `scheduleRestart`/`cancelRestart`
878
+ * to drive it deterministically in tests.
879
+ */
880
+ declare function createNativeCaptureRunner(options: NativeRunnerOptions): NativeCaptureRunner;
881
+
882
+ /**
883
+ * Chunk processor (issue #1897) — turns completed native WAV chunk events
884
+ * into durable, replay-safe conversations in the spool.
885
+ *
886
+ * The native helper runner owns process lifecycle and emits one validated
887
+ * `ChunkEvent` per recorded WAV. This module consumes those events through a
888
+ * single serialized promise chain: resolve model -> transcribe -> normalize
889
+ * nonempty segments -> assemble -> persist via durable chunk idempotency ->
890
+ * delete the raw WAV. A rejected chunk is reported and the chain recovers so
891
+ * the daemon stays alive; the durable `applied_chunks` guard keeps a
892
+ * restart/replay of the same chunk from duplicating segments.
893
+ *
894
+ * Every collaborator (STT, model resolution, raw-audio cleanup) is injected,
895
+ * so no optional VAD/native runtime is imported here and the package stays
896
+ * à-la-carte.
897
+ */
898
+
899
+ interface ChunkTranscribeInput {
900
+ wavPath: string;
901
+ modelPath: string;
902
+ chunkStartedAtUtc: string;
903
+ }
904
+ interface ChunkProcessorDeps {
905
+ spool: Spool;
906
+ /** Stateful assembler that groups consecutive segments into conversations. */
907
+ assembler: ConversationAssembler;
908
+ /** Resolve the STT model path; called per speech chunk and may throw when absent. */
909
+ resolveModel: () => string;
910
+ /** Transcribe one WAV chunk into raw segments. */
911
+ transcribe: (input: ChunkTranscribeInput) => Promise<TranscribedSegment[]>;
912
+ /** Delete the raw WAV under retention once the chunk is durably persisted. */
913
+ cleanupRawAudio: (event: ChunkEvent) => Promise<void>;
914
+ /**
915
+ * VAD speech gate. When provided and it resolves false, the chunk is treated
916
+ * as non-speech: STT is skipped (the CPU-budget guard) and no segments
917
+ * persist. Absent -> every chunk is transcribed.
918
+ */
919
+ detectSpeech?: (event: ChunkEvent) => boolean | Promise<boolean>;
920
+ /**
921
+ * Speaker-embedding extractor for diarization. With `diarizer`, each segment
922
+ * is embedded and assigned to a speaker cluster; absent -> the interim
923
+ * mic=wearer heuristic and no speaker cluster.
924
+ */
925
+ embed?: (event: ChunkEvent, segment: TranscribedSegment) => Embedding | Promise<Embedding>;
926
+ /** Speaker clusterer (seeded from the spool); its clusters are persisted on finalize. */
927
+ diarizer?: SpeakerClusterer;
928
+ /** Cross-channel dedup window in ms; defaults to the dedup module's tolerance. */
929
+ dedupWindowMs?: number;
930
+ /** Reports a per-chunk failure; the chain keeps running afterwards. */
931
+ onError?: (error: Error, event: ChunkEvent) => void;
932
+ }
933
+ interface ChunkProcessor {
934
+ /** onChunk seam for `NativeRunnerOptions`. Never throws; failures route to `onError`. */
935
+ enqueue(event: ChunkEvent): void;
936
+ /** Resolve once the serialized chain has settled all enqueued chunks. */
937
+ drain(): Promise<void>;
938
+ /** Drain, then flip open conversations to `final`; returns the count closed. */
939
+ finalize(): Promise<number>;
940
+ }
941
+ /**
942
+ * Stable chunk identity derived purely from the WAV path. Because it never
943
+ * depends on a freshly-generated conversation id, the same chunk yields the
944
+ * same idempotency key across process restarts.
945
+ */
946
+ declare function chunkStableId(event: ChunkEvent): string;
947
+ declare function createChunkProcessor(deps: ChunkProcessorDeps): ChunkProcessor;
948
+
949
+ /**
950
+ * Live capture wiring (issue #1897) — assembles the native helper runner and
951
+ * the chunk processor into one start/stop unit the daemon drives.
952
+ *
953
+ * The native runner owns the helper process and surfaces validated
954
+ * `ChunkEvent`s; the processor turns each recorded WAV into durable, replay-safe
955
+ * conversations in the spool. This module wires them with production defaults
956
+ * (whisper STT, model resolution, raw-audio cleanup) while keeping every
957
+ * collaborator injectable, so tests drive the whole pipeline against a fake
958
+ * helper binary and a fake transcriber without any macOS runtime.
959
+ */
960
+
961
+ interface LiveCaptureOptions {
962
+ spool: Spool;
963
+ config: DaemonConfig;
964
+ /** Directory the helper writes WAV chunks into (`audio-capture --out`). */
965
+ outDir: string;
966
+ /** Default whisper model path when `config.stt.modelPath` is unset. */
967
+ defaultModelPath: string;
968
+ onError?: (error: Error) => void;
969
+ onStderr?: (line: string) => void;
970
+ spawn?: HelperSpawn;
971
+ resolveBinary?: (deps: ResolveHelperDeps) => HelperResolution;
972
+ resolution?: HelperResolution;
973
+ transcribe?: (input: ChunkTranscribeInput) => Promise<TranscribedSegment[]>;
974
+ resolveModel?: () => string;
975
+ cleanupRawAudio?: (event: ChunkEvent) => Promise<void>;
976
+ scheduleRestart?: (fn: () => void, delayMs: number) => RestartTimer;
977
+ cancelRestart?: (timer: RestartTimer) => void;
978
+ makeConversationId?: () => string;
979
+ /** VAD speech gate seam; production supplies a sherpa-onnx detector. */
980
+ detectSpeech?: (event: ChunkEvent) => boolean | Promise<boolean>;
981
+ /** Speaker-embedding seam for diarization; production supplies sherpa speaker-id. */
982
+ embed?: (event: ChunkEvent, segment: TranscribedSegment) => Embedding | Promise<Embedding>;
983
+ }
984
+ interface LiveCapture {
985
+ start(): void;
986
+ /** Stop the helper, then drain + finalize the processor. */
987
+ stop(): Promise<number>;
988
+ readonly running: boolean;
989
+ /** Test/observability seam. */
990
+ readonly processor: ChunkProcessor;
991
+ }
992
+ /** Wire the native runner + chunk processor into one live-capture unit. */
993
+ declare function createLiveCapture(options: LiveCaptureOptions): LiveCapture;
994
+
995
+ /**
996
+ * `install-service` support (issue #1897) — render and install a per-user
997
+ * background service that runs the capture-audio daemon in live-capture mode.
998
+ *
999
+ * macOS uses a launchd LaunchAgent (`~/Library/LaunchAgents/<label>.plist`);
1000
+ * Linux uses a systemd user unit (`~/.config/systemd/user/<name>.service`).
1001
+ * The renderers are pure (deterministic strings) and `installService` /
1002
+ * `uninstallService` take injected filesystem + environment seams so the whole
1003
+ * surface is testable without touching the real user launch directories.
1004
+ */
1005
+ declare const DEFAULT_SERVICE_LABEL = "com.remnic.capture-audio";
1006
+ interface ServiceSpec {
1007
+ /** argv that launches the daemon, e.g. [node, cliEntry, "start", "--foreground", "--capture"]. */
1008
+ programArguments: string[];
1009
+ logPath: string;
1010
+ label?: string;
1011
+ /** Env vars the launched daemon needs (e.g. PATH, REMNIC_CAPTURE_HELPER_BIN). */
1012
+ environment?: Record<string, string>;
1013
+ }
1014
+ interface ServicePlan {
1015
+ platform: NodeJS.Platform;
1016
+ /** Absolute path the unit file is written to. */
1017
+ path: string;
1018
+ contents: string;
1019
+ /** One-line operator instruction to load/enable the service. */
1020
+ loadHint: string;
1021
+ }
1022
+ /** Render a launchd LaunchAgent plist that keeps the daemon alive at login. */
1023
+ declare function renderLaunchAgent(spec: ServiceSpec): string;
1024
+ /** Render a systemd user unit that restarts the daemon on failure. */
1025
+ declare function renderSystemdUnit(spec: ServiceSpec): string;
1026
+ interface PlanServiceDeps {
1027
+ platform: NodeJS.Platform;
1028
+ home: string;
1029
+ spec: ServiceSpec;
1030
+ }
1031
+ /** Decide the unit file path + contents for the current platform. */
1032
+ declare function planService(deps: PlanServiceDeps): ServicePlan;
1033
+ interface InstallServiceDeps extends PlanServiceDeps {
1034
+ mkdir: (dir: string) => void;
1035
+ writeFile: (file: string, contents: string) => void;
1036
+ /** True to overwrite an already-installed unit. */
1037
+ force?: boolean;
1038
+ exists?: (file: string) => boolean;
1039
+ }
1040
+ /** Write the planned unit file, creating its directory. Returns the plan. */
1041
+ declare function installService(deps: InstallServiceDeps): ServicePlan;
1042
+ interface UninstallServiceDeps extends PlanServiceDeps {
1043
+ remove: (file: string) => void;
1044
+ exists: (file: string) => boolean;
1045
+ }
1046
+ /** Remove the installed unit file. Returns the plan + whether a file was removed. */
1047
+ declare function uninstallService(deps: UninstallServiceDeps): {
1048
+ plan: ServicePlan;
1049
+ removed: boolean;
1050
+ };
1051
+
1052
+ /**
1053
+ * `enroll-self` (issue #1897) — register the wearer as the `self` speaker so
1054
+ * the diarizer and downstream attribution can distinguish the wearer's own
1055
+ * voice from everyone else's.
1056
+ *
1057
+ * Enrollment stores a durable `self` speaker row. When a voice embedding is
1058
+ * supplied (extracted from a recorded sample by a speaker-embedding model —
1059
+ * à-la-carte, like whisper STT and the Silero VAD), it is stored as the self
1060
+ * centroid so live diarization can match against it. Without an embedder the
1061
+ * wearer's identity is still registered (embedding refinement lands with the
1062
+ * diarization slice), so the pipeline can already tag `desktop:self`.
1063
+ *
1064
+ * Pure over its injected `spool`: the caller owns recording the sample and
1065
+ * extracting the embedding, so no optional native/model runtime is imported.
1066
+ */
1067
+
1068
+ /** The stable speaker id for the enrolled wearer. */
1069
+ declare const SELF_SPEAKER_ID = "self";
1070
+ interface EnrollSelfInput {
1071
+ spool: Spool;
1072
+ /** Human label for the wearer; defaults to "You". */
1073
+ label?: string | null;
1074
+ /** Optional wearer voice embedding; when present it is stored as the self centroid. */
1075
+ embedding?: Embedding;
1076
+ }
1077
+ interface EnrollSelfResult {
1078
+ speakerId: string;
1079
+ label: string | null;
1080
+ hasEmbedding: boolean;
1081
+ dimensions: number;
1082
+ }
1083
+ /**
1084
+ * Register (or refresh) the `self` speaker. With an embedding, the canonical
1085
+ * self cluster (centroid + example) is persisted; without one, only the
1086
+ * identity is upserted, preserving any embedding a prior enroll stored.
1087
+ */
1088
+ declare function enrollSelf(input: EnrollSelfInput): EnrollSelfResult;
1089
+
1090
+ export { type AssembledConversation, type AssemblerOptions, type AssemblyAppendInput, type AssemblyAppendResult, type AssemblySegment, CAPTURE_AUDIO_VERSION, CaptureConfigError, CaptureInputError, type CapturePaths, type ChannelSelection, type ChunkEvent, type ChunkProcessor, type ChunkProcessorDeps, type ChunkStatus, type ChunkTranscribeInput, type CliIo, ConversationAssembler, type ConversationInput, type ConversationPage, type ConversationState, type Cursor, DEFAULT_CONVERSATION_GAP_MINUTES, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SERVICE_LABEL, DESKTOP_SOURCE_ID, type DaemonConfig, type DaemonConversation, type DaemonDeps, type DaemonHandle, type DaemonSegment, type DedupSegment, DesktopDaemonError, type DeviceConfig, type DiarizationConfig, type Embedding, type EnrollSelfInput, type EnrollSelfResult, HELPER_BIN_ENV, type HelperResolution, type HelperSpawn, type LiveCapture, type LiveCaptureOptions, type ModelDownloadInput, type ModelDownloadResult, type NativeCaptureRunner, type NativeRunnerOptions, type PidRecord, type QueryFinalOptions, REPLAY_COMMIT_BATCH, type ReplayResult, type ResolveHelperDeps, SELF_SPEAKER_ID, SPOOL_SCHEMA_VERSION, type SegmentInput, type ServicePlan, type ServiceSpec, type SherpaOnnxModule, type SileroVadInput, type SpeakerCluster, type SpeakerClusterRow, SpeakerClusterer, type SpeakerInput, type SpeakerRow, Spool, type SttConfig, type TranscribedSegment, type VadConfig, type WhisperRunResult, type WhisperTranscriptionInput, assembleConversations, assertValidTimezone, bearerFromHeader, buildHelperArgs, buildWhisperArgs, captureBaseDir, capturePaths, chunkStableId, cosineSimilarity, createChunkProcessor, createDesktopConnector, createLiveCapture, createNativeCaptureRunner, createRequestHandler, createSileroVad, daemonConversationToWearable, decodeCursor, dedupeCrossChannel, defaultDaemonConfig, downloadWhisperModel, encodeCursor, enrollSelf, ensureDesktopConnectorRegistered, enumerateDevices, generateToken, helperPackageSpecifier, ingestReplayDir, ingestReplayDirResponsive, installService, isProcessAlive, loadDaemonConfig, loadOrCreateToken, loadSherpaOnnx, parseChunkEvent, parseDaemonConfig, parseLimit, parseTranscriptDate, parseWhisperJson, planService, pruneExpiredRawAudio, readPidFile, readPidRecord, removePidFile, removePidFileIfOwner, renderLaunchAgent, renderSystemdUnit, resolveCaptureAudioToken, resolveHelperBinary, resolveModelPath, runCapture, runWhisperCli, serializeDaemonConfig, sileroVadConfig, startDaemon, superviseReplay, tokensMatch, transcribeWithWhisper, uninstallService, wearableConnectorRegistration, whisperModelUrl, wordJaccard, writePidFile };