@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.
package/dist/index.js ADDED
@@ -0,0 +1,405 @@
1
+ // openclaw-engram: Local-first memory plugin
2
+ import {
3
+ CAPTURE_AUDIO_VERSION,
4
+ CaptureConfigError,
5
+ CaptureInputError,
6
+ ConversationAssembler,
7
+ DEFAULT_CONVERSATION_GAP_MINUTES,
8
+ DEFAULT_HOST,
9
+ DEFAULT_PORT,
10
+ DEFAULT_SERVICE_LABEL,
11
+ HELPER_BIN_ENV,
12
+ REPLAY_COMMIT_BATCH,
13
+ SELF_SPEAKER_ID,
14
+ SPOOL_SCHEMA_VERSION,
15
+ SpeakerClusterer,
16
+ Spool,
17
+ assembleConversations,
18
+ assertValidTimezone,
19
+ bearerFromHeader,
20
+ buildHelperArgs,
21
+ buildWhisperArgs,
22
+ captureBaseDir,
23
+ capturePaths,
24
+ chunkStableId,
25
+ cosineSimilarity,
26
+ createChunkProcessor,
27
+ createLiveCapture,
28
+ createNativeCaptureRunner,
29
+ createRequestHandler,
30
+ decodeCursor,
31
+ dedupeCrossChannel,
32
+ defaultDaemonConfig,
33
+ downloadWhisperModel,
34
+ encodeCursor,
35
+ enrollSelf,
36
+ enumerateDevices,
37
+ expandTilde,
38
+ generateToken,
39
+ helperPackageSpecifier,
40
+ ingestReplayDir,
41
+ ingestReplayDirResponsive,
42
+ installService,
43
+ isLoopbackHost,
44
+ isProcessAlive,
45
+ loadDaemonConfig,
46
+ loadOrCreateToken,
47
+ parseChunkEvent,
48
+ parseDaemonConfig,
49
+ parseLimit,
50
+ parseTranscriptDate,
51
+ parseWhisperJson,
52
+ planService,
53
+ pruneExpiredRawAudio,
54
+ readPidFile,
55
+ readPidRecord,
56
+ removePidFile,
57
+ removePidFileIfOwner,
58
+ renderLaunchAgent,
59
+ renderSystemdUnit,
60
+ resolveHelperBinary,
61
+ resolveModelPath,
62
+ runCapture,
63
+ runWhisperCli,
64
+ serializeDaemonConfig,
65
+ startDaemon,
66
+ superviseReplay,
67
+ tokensMatch,
68
+ transcribeWithWhisper,
69
+ uninstallService,
70
+ whisperModelUrl,
71
+ wordJaccard,
72
+ writePidFile
73
+ } from "./chunk-BRVXKUZY.js";
74
+
75
+ // src/vad.ts
76
+ import { statSync } from "fs";
77
+ function sileroVadConfig(input) {
78
+ if (typeof input.modelPath !== "string" || input.modelPath.trim() === "") {
79
+ throw new CaptureConfigError("Silero VAD modelPath must be a non-empty string");
80
+ }
81
+ if (!Number.isFinite(input.minSpeechMs) || input.minSpeechMs <= 0) {
82
+ throw new CaptureConfigError("Silero VAD minSpeechMs must be positive");
83
+ }
84
+ const minSilenceMs = input.minSilenceMs ?? 500;
85
+ const maxSpeechMs = input.maxSpeechMs ?? 3e4;
86
+ const threshold = input.threshold ?? 0.5;
87
+ const threads = input.threads ?? 1;
88
+ if (!Number.isFinite(minSilenceMs) || minSilenceMs < 0 || !Number.isFinite(maxSpeechMs) || maxSpeechMs <= 0) {
89
+ throw new CaptureConfigError("Silero VAD duration settings are invalid");
90
+ }
91
+ if (maxSpeechMs < input.minSpeechMs) {
92
+ throw new CaptureConfigError("Silero VAD maxSpeechMs must be greater than or equal to minSpeechMs");
93
+ }
94
+ if (!Number.isFinite(threshold) || threshold <= 0 || threshold >= 1) {
95
+ throw new CaptureConfigError("Silero VAD threshold must be between 0 and 1");
96
+ }
97
+ if (!Number.isInteger(threads) || threads <= 0) {
98
+ throw new CaptureConfigError("Silero VAD threads must be a positive integer");
99
+ }
100
+ return {
101
+ config: {
102
+ sileroVad: {
103
+ model: expandTilde(input.modelPath),
104
+ threshold,
105
+ minSpeechDuration: input.minSpeechMs / 1e3,
106
+ minSilenceDuration: minSilenceMs / 1e3,
107
+ maxSpeechDuration: maxSpeechMs / 1e3,
108
+ windowSize: 512
109
+ },
110
+ sampleRate: 16e3,
111
+ debug: false,
112
+ numThreads: threads
113
+ },
114
+ bufferSeconds: 60
115
+ };
116
+ }
117
+ function resolveSherpaExport(module) {
118
+ const candidates = [module, module?.default];
119
+ for (const candidate of candidates) {
120
+ if (candidate && typeof candidate === "object" && typeof candidate.Vad === "function") {
121
+ return candidate;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ async function loadSherpaOnnx(importModule = (specifier) => import(specifier)) {
127
+ const specifier = "sherpa-onnx-node";
128
+ let module;
129
+ try {
130
+ module = await importModule(specifier);
131
+ } catch (error) {
132
+ const err = error;
133
+ const message = error instanceof Error ? error.message : "";
134
+ const packageMissing = err.code === "ERR_MODULE_NOT_FOUND" && err.requireStack === void 0 && message.includes(`package '${specifier}'`);
135
+ if (packageMissing) {
136
+ throw new CaptureConfigError(
137
+ "Silero VAD requires optional dependency sherpa-onnx-node; install it before enabling VAD"
138
+ );
139
+ }
140
+ throw new CaptureConfigError(
141
+ "Silero VAD could not load the installed sherpa-onnx-node native runtime; verify its native build and shared-library dependencies"
142
+ );
143
+ }
144
+ const resolved = resolveSherpaExport(module);
145
+ if (!resolved) {
146
+ throw new CaptureConfigError("sherpa-onnx-node does not expose the Vad API required by capture-audio");
147
+ }
148
+ return resolved;
149
+ }
150
+ function isRegularFile(filePath) {
151
+ try {
152
+ return statSync(filePath).isFile();
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+ async function createSileroVad(input, load = loadSherpaOnnx, exists = isRegularFile) {
158
+ const { config, bufferSeconds } = sileroVadConfig(input);
159
+ const modelPath = expandTilde(input.modelPath);
160
+ if (!exists(modelPath)) {
161
+ throw new CaptureConfigError(`Silero VAD model not found at ${modelPath}; set vad.modelPath to a readable model file`);
162
+ }
163
+ const { Vad } = await load();
164
+ return new Vad(config, bufferSeconds);
165
+ }
166
+
167
+ // src/connector.ts
168
+ import { readFileSync } from "fs";
169
+ import {
170
+ registerWearableConnector,
171
+ getWearableConnector
172
+ } from "@remnic/core";
173
+ var DESKTOP_SOURCE_ID = "desktop";
174
+ var DESKTOP_DISPLAY_NAME = "Desktop audio";
175
+ var DEFAULT_BASE_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
176
+ var PROBE_TIMEOUT_MS = 15e3;
177
+ function withTimeout(signal) {
178
+ const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);
179
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
180
+ }
181
+ var DesktopDaemonError = class extends Error {
182
+ constructor(message) {
183
+ super(message);
184
+ this.name = "DesktopDaemonError";
185
+ }
186
+ };
187
+ function baseUrlIsLoopback(baseUrl) {
188
+ try {
189
+ return isLoopbackHost(new URL(baseUrl).hostname);
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+ function resolveCaptureAudioToken(configured, baseUrl, env = process.env) {
195
+ if (typeof configured === "string" && configured.trim().length > 0) return configured.trim();
196
+ const fromEnv = env.REMNIC_CAPTURE_AUDIO_TOKEN;
197
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
198
+ if (baseUrlIsLoopback(baseUrl)) {
199
+ try {
200
+ const token = readFileSync(capturePaths().tokenPath, "utf8").trim();
201
+ if (token.length > 0) return token;
202
+ } catch {
203
+ }
204
+ }
205
+ return void 0;
206
+ }
207
+ function daemonSegmentToWearable(seg) {
208
+ return {
209
+ text: seg.textRaw,
210
+ // The wearables speaker registry owns naming; the connector only
211
+ // supplies the stable per-source key (spk_<n> | self | "unknown").
212
+ speakerKey: seg.speakerKey ?? "unknown",
213
+ isWearer: seg.isWearer,
214
+ startIso: seg.startUtc,
215
+ endIso: seg.endUtc
216
+ };
217
+ }
218
+ function daemonConversationToWearable(conv) {
219
+ return {
220
+ id: conv.id,
221
+ source: DESKTOP_SOURCE_ID,
222
+ startIso: conv.startedAtUtc,
223
+ endIso: conv.endedAtUtc ?? void 0,
224
+ segments: conv.segments.map(daemonSegmentToWearable)
225
+ };
226
+ }
227
+ function trimTrailingSlashes(value) {
228
+ let end = value.length;
229
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
230
+ return value.slice(0, end);
231
+ }
232
+ var DesktopClient = class {
233
+ #baseUrl;
234
+ #getToken;
235
+ constructor(options) {
236
+ this.#baseUrl = trimTrailingSlashes(options.baseUrl);
237
+ this.#getToken = options.getToken;
238
+ }
239
+ #headers() {
240
+ const token = this.#getToken();
241
+ return token ? { authorization: `Bearer ${token}` } : {};
242
+ }
243
+ async verifyAuth(signal) {
244
+ let res;
245
+ try {
246
+ res = await fetch(`${this.#baseUrl}/v1/health`, { headers: this.#headers(), signal: withTimeout(signal) });
247
+ } catch (err) {
248
+ if (signal?.aborted) throw err;
249
+ return { ok: false, detail: "unreachable" };
250
+ }
251
+ if (res.status === 401) return { ok: false, detail: "unauthorized" };
252
+ if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` };
253
+ let body;
254
+ try {
255
+ body = await res.json();
256
+ } catch (err) {
257
+ if (signal?.aborted) throw err;
258
+ body = {};
259
+ }
260
+ if (body.ok === true) {
261
+ return { ok: true, detail: typeof body.version === "string" ? `capture-audio ${body.version}` : void 0 };
262
+ }
263
+ return { ok: false, detail: "unhealthy" };
264
+ }
265
+ async fetchConversations(opts) {
266
+ let res;
267
+ try {
268
+ const url = new URL(`${this.#baseUrl}/v1/conversations`);
269
+ url.searchParams.set("date", opts.date);
270
+ url.searchParams.set("timezone", opts.timezone);
271
+ if (opts.cursor) url.searchParams.set("cursor", opts.cursor);
272
+ res = await fetch(url, { headers: this.#headers(), signal: withTimeout(opts.signal) });
273
+ } catch (err) {
274
+ if (opts.signal?.aborted) throw err;
275
+ throw new DesktopDaemonError(
276
+ `desktop capture daemon unreachable at ${this.#baseUrl} (is remnic-capture-audio running?)`
277
+ );
278
+ }
279
+ if (!res.ok) {
280
+ throw new DesktopDaemonError(`desktop capture daemon returned HTTP ${res.status}`);
281
+ }
282
+ let page;
283
+ try {
284
+ page = await res.json();
285
+ } catch (err) {
286
+ if (opts.signal?.aborted) throw err;
287
+ throw new DesktopDaemonError("desktop capture daemon returned a non-JSON conversations response");
288
+ }
289
+ if (!page || !Array.isArray(page.conversations)) {
290
+ throw new DesktopDaemonError("desktop capture daemon returned a malformed conversations page");
291
+ }
292
+ return {
293
+ conversations: page.conversations.map(daemonConversationToWearable),
294
+ nextCursor: page.nextCursor ?? null
295
+ };
296
+ }
297
+ };
298
+ function createDesktopConnector(options) {
299
+ const baseUrl = options.settings.baseUrl?.trim() || DEFAULT_BASE_URL;
300
+ let client = null;
301
+ const getClient = () => {
302
+ if (!client) {
303
+ client = new DesktopClient({ baseUrl, getToken: () => resolveCaptureAudioToken(options.settings.apiKey, baseUrl) });
304
+ }
305
+ return client;
306
+ };
307
+ return {
308
+ id: DESKTOP_SOURCE_ID,
309
+ displayName: DESKTOP_DISPLAY_NAME,
310
+ verifyAuth: (signal) => getClient().verifyAuth(signal),
311
+ fetchConversations: (opts) => getClient().fetchConversations(opts)
312
+ };
313
+ }
314
+ var wearableConnectorRegistration = {
315
+ id: DESKTOP_SOURCE_ID,
316
+ displayName: DESKTOP_DISPLAY_NAME,
317
+ factory: createDesktopConnector
318
+ };
319
+ function ensureDesktopConnectorRegistered() {
320
+ if (getWearableConnector(DESKTOP_SOURCE_ID) !== void 0) return false;
321
+ registerWearableConnector(wearableConnectorRegistration);
322
+ return true;
323
+ }
324
+ ensureDesktopConnectorRegistered();
325
+ export {
326
+ CAPTURE_AUDIO_VERSION,
327
+ CaptureConfigError,
328
+ CaptureInputError,
329
+ ConversationAssembler,
330
+ DEFAULT_CONVERSATION_GAP_MINUTES,
331
+ DEFAULT_HOST,
332
+ DEFAULT_PORT,
333
+ DEFAULT_SERVICE_LABEL,
334
+ DESKTOP_SOURCE_ID,
335
+ DesktopDaemonError,
336
+ HELPER_BIN_ENV,
337
+ REPLAY_COMMIT_BATCH,
338
+ SELF_SPEAKER_ID,
339
+ SPOOL_SCHEMA_VERSION,
340
+ SpeakerClusterer,
341
+ Spool,
342
+ assembleConversations,
343
+ assertValidTimezone,
344
+ bearerFromHeader,
345
+ buildHelperArgs,
346
+ buildWhisperArgs,
347
+ captureBaseDir,
348
+ capturePaths,
349
+ chunkStableId,
350
+ cosineSimilarity,
351
+ createChunkProcessor,
352
+ createDesktopConnector,
353
+ createLiveCapture,
354
+ createNativeCaptureRunner,
355
+ createRequestHandler,
356
+ createSileroVad,
357
+ daemonConversationToWearable,
358
+ decodeCursor,
359
+ dedupeCrossChannel,
360
+ defaultDaemonConfig,
361
+ downloadWhisperModel,
362
+ encodeCursor,
363
+ enrollSelf,
364
+ ensureDesktopConnectorRegistered,
365
+ enumerateDevices,
366
+ generateToken,
367
+ helperPackageSpecifier,
368
+ ingestReplayDir,
369
+ ingestReplayDirResponsive,
370
+ installService,
371
+ isProcessAlive,
372
+ loadDaemonConfig,
373
+ loadOrCreateToken,
374
+ loadSherpaOnnx,
375
+ parseChunkEvent,
376
+ parseDaemonConfig,
377
+ parseLimit,
378
+ parseTranscriptDate,
379
+ parseWhisperJson,
380
+ planService,
381
+ pruneExpiredRawAudio,
382
+ readPidFile,
383
+ readPidRecord,
384
+ removePidFile,
385
+ removePidFileIfOwner,
386
+ renderLaunchAgent,
387
+ renderSystemdUnit,
388
+ resolveCaptureAudioToken,
389
+ resolveHelperBinary,
390
+ resolveModelPath,
391
+ runCapture,
392
+ runWhisperCli,
393
+ serializeDaemonConfig,
394
+ sileroVadConfig,
395
+ startDaemon,
396
+ superviseReplay,
397
+ tokensMatch,
398
+ transcribeWithWhisper,
399
+ uninstallService,
400
+ wearableConnectorRegistration,
401
+ whisperModelUrl,
402
+ wordJaccard,
403
+ writePidFile
404
+ };
405
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/vad.ts","../src/connector.ts"],"sourcesContent":["import { statSync } from \"node:fs\";\n\nimport { CaptureConfigError } from \"./errors.js\";\nimport { expandTilde } from \"./paths.js\";\n\n\nexport interface SileroVadInput {\n modelPath: string;\n minSpeechMs: number;\n minSilenceMs?: number;\n maxSpeechMs?: number;\n threshold?: number;\n threads?: number;\n}\n\nexport interface SherpaOnnxModule {\n Vad: new (config: unknown, bufferSeconds: number) => unknown;\n}\n\nexport function sileroVadConfig(input: SileroVadInput): { config: object; bufferSeconds: number } {\n if (typeof input.modelPath !== \"string\" || input.modelPath.trim() === \"\") {\n throw new CaptureConfigError(\"Silero VAD modelPath must be a non-empty string\");\n }\n if (!Number.isFinite(input.minSpeechMs) || input.minSpeechMs <= 0) {\n throw new CaptureConfigError(\"Silero VAD minSpeechMs must be positive\");\n }\n const minSilenceMs = input.minSilenceMs ?? 500;\n const maxSpeechMs = input.maxSpeechMs ?? 30_000;\n const threshold = input.threshold ?? 0.5;\n const threads = input.threads ?? 1;\n if (!Number.isFinite(minSilenceMs) || minSilenceMs < 0 || !Number.isFinite(maxSpeechMs) || maxSpeechMs <= 0) {\n throw new CaptureConfigError(\"Silero VAD duration settings are invalid\");\n }\n if (maxSpeechMs < input.minSpeechMs) {\n throw new CaptureConfigError(\"Silero VAD maxSpeechMs must be greater than or equal to minSpeechMs\");\n }\n if (!Number.isFinite(threshold) || threshold <= 0 || threshold >= 1) {\n throw new CaptureConfigError(\"Silero VAD threshold must be between 0 and 1\");\n }\n if (!Number.isInteger(threads) || threads <= 0) {\n throw new CaptureConfigError(\"Silero VAD threads must be a positive integer\");\n }\n return {\n config: {\n sileroVad: {\n model: expandTilde(input.modelPath),\n threshold,\n minSpeechDuration: input.minSpeechMs / 1_000,\n minSilenceDuration: minSilenceMs / 1_000,\n maxSpeechDuration: maxSpeechMs / 1_000,\n windowSize: 512,\n },\n sampleRate: 16_000,\n debug: false,\n numThreads: threads,\n },\n bufferSeconds: 60,\n };\n}\n\nexport function resolveSherpaExport(module: unknown): SherpaOnnxModule | null {\n const candidates = [module, (module as { default?: unknown } | null)?.default];\n for (const candidate of candidates) {\n if (candidate && typeof candidate === \"object\" && typeof (candidate as SherpaOnnxModule).Vad === \"function\") {\n return candidate as SherpaOnnxModule;\n }\n }\n return null;\n}\n\nexport async function loadSherpaOnnx(\n importModule: (specifier: string) => Promise<unknown> = (specifier) => import(specifier),\n): Promise<SherpaOnnxModule> {\n // Computed specifier keeps the optional sherpa-onnx-node peer out of the static\n // dependency graph so the package builds and runs without it installed.\n const specifier = \"sherpa-onnx-\" + \"node\";\n let module: unknown;\n try {\n module = await importModule(specifier);\n } catch (error) {\n // A genuinely absent package surfaces as ERR_MODULE_NOT_FOUND naming the\n // specifier as a package, with no require stack. An installed-but-broken\n // package (missing native .node, dlopen failure) has a different code\n // and/or a require stack, so it must NOT be reported as \"not installed\".\n const err = error as NodeJS.ErrnoException & { requireStack?: unknown };\n const message = error instanceof Error ? error.message : \"\";\n const packageMissing =\n err.code === \"ERR_MODULE_NOT_FOUND\" &&\n err.requireStack === undefined &&\n message.includes(`package '${specifier}'`);\n if (packageMissing) {\n throw new CaptureConfigError(\n \"Silero VAD requires optional dependency sherpa-onnx-node; install it before enabling VAD\",\n );\n }\n // Installed but unusable. Report that distinctly, but keep the message\n // operator-safe: never echo the raw error (it can leak absolute native\n // library paths), per the CaptureConfigError contract.\n throw new CaptureConfigError(\n \"Silero VAD could not load the installed sherpa-onnx-node native runtime; verify its native build and shared-library dependencies\",\n );\n }\n const resolved = resolveSherpaExport(module);\n if (!resolved) {\n throw new CaptureConfigError(\"sherpa-onnx-node does not expose the Vad API required by capture-audio\");\n }\n return resolved;\n}\n\nfunction isRegularFile(filePath: string): boolean {\n try {\n return statSync(filePath).isFile();\n } catch {\n return false;\n }\n}\n\nexport async function createSileroVad(\n input: SileroVadInput,\n load: () => Promise<SherpaOnnxModule> = loadSherpaOnnx,\n exists: (path: string) => boolean = isRegularFile,\n): Promise<unknown> {\n const { config, bufferSeconds } = sileroVadConfig(input);\n const modelPath = expandTilde(input.modelPath);\n if (!exists(modelPath)) {\n throw new CaptureConfigError(`Silero VAD model not found at ${modelPath}; set vad.modelPath to a readable model file`);\n }\n const { Vad } = await load();\n return new Vad(config, bufferSeconds);\n}\n","/**\n * `desktop` wearable source connector (issue #1897, component 4).\n *\n * À-la-carte optional companion of @remnic/core: installing core alone\n * never pulls this in; core discovers it at runtime via a\n * computed-specifier dynamic import (registry entry {id:\"desktop\",\n * suffix:\"capture-audio\"}) or via a direct import of @remnic/capture-audio,\n * which self-registers idempotently.\n *\n * The connector is a pure API client + normalizer over the capture-audio\n * daemon's loopback HTTP API: no file IO beyond reading the local token,\n * no memory writes, no pipeline behavior (all of that stays in core so\n * desktop audio gets the same cleanup/corrections/trust gating as every\n * other wearable source).\n *\n * Token resolution (in order): settings.apiKey (config) ->\n * REMNIC_CAPTURE_AUDIO_TOKEN env -> the daemon's local token file\n * (~/.remnic/capture/token) when the base URL is loopback.\n */\n\nimport { readFileSync } from \"node:fs\";\n\nimport {\n registerWearableConnector,\n getWearableConnector,\n type WearableAuthCheck,\n type WearableConnectorFactoryOptions,\n type WearableConnectorRegistration,\n type WearableConversation,\n type WearableFetchOptions,\n type WearableFetchPage,\n type WearableSourceConnector,\n type WearableTranscriptSegment,\n} from \"@remnic/core\";\n\nimport { DEFAULT_HOST, DEFAULT_PORT } from \"./constants.js\";\nimport { capturePaths } from \"./paths.js\";\nimport { isLoopbackHost } from \"./util.js\";\nimport type { ConversationPage, DaemonConversation, DaemonSegment } from \"./spool.js\";\n\nexport const DESKTOP_SOURCE_ID = \"desktop\";\nexport const DESKTOP_DISPLAY_NAME = \"Desktop audio\";\nconst DEFAULT_BASE_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;\n\n/** Bounded probe timeout so a wedged daemon reports unreachable instead of hanging. */\nconst PROBE_TIMEOUT_MS = 15_000;\n\nfunction withTimeout(signal: AbortSignal | undefined): AbortSignal {\n const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);\n return signal ? AbortSignal.any([signal, timeout]) : timeout;\n}\n\n/** Error raised for a genuine backend failure (never for an empty day). */\nexport class DesktopDaemonError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"DesktopDaemonError\";\n }\n}\n\nfunction baseUrlIsLoopback(baseUrl: string): boolean {\n try {\n return isLoopbackHost(new URL(baseUrl).hostname);\n } catch {\n return false;\n }\n}\n\n/**\n * Resolve the daemon bearer token. The local token file is read ONLY for\n * a loopback base URL — a remote reader must supply the token explicitly\n * (config/env), never inherit this machine's local token.\n */\nexport function resolveCaptureAudioToken(\n configured: string | undefined,\n baseUrl: string,\n env: NodeJS.ProcessEnv = process.env,\n): string | undefined {\n if (typeof configured === \"string\" && configured.trim().length > 0) return configured.trim();\n const fromEnv = env.REMNIC_CAPTURE_AUDIO_TOKEN;\n if (typeof fromEnv === \"string\" && fromEnv.trim().length > 0) return fromEnv.trim();\n if (baseUrlIsLoopback(baseUrl)) {\n try {\n const token = readFileSync(capturePaths().tokenPath, \"utf8\").trim();\n if (token.length > 0) return token;\n } catch {\n // no local token file — treat as unauthenticated (health will 401)\n }\n }\n return undefined;\n}\n\nfunction daemonSegmentToWearable(seg: DaemonSegment): WearableTranscriptSegment {\n return {\n text: seg.textRaw,\n // The wearables speaker registry owns naming; the connector only\n // supplies the stable per-source key (spk_<n> | self | \"unknown\").\n speakerKey: seg.speakerKey ?? \"unknown\",\n isWearer: seg.isWearer,\n startIso: seg.startUtc,\n endIso: seg.endUtc,\n };\n}\n\nexport function daemonConversationToWearable(conv: DaemonConversation): WearableConversation {\n return {\n id: conv.id,\n source: DESKTOP_SOURCE_ID,\n startIso: conv.startedAtUtc,\n endIso: conv.endedAtUtc ?? undefined,\n segments: conv.segments.map(daemonSegmentToWearable),\n };\n}\n\ninterface DesktopClientOptions {\n baseUrl: string;\n /** Resolved per request so env / token-file rotation applies without a rebuild. */\n getToken: () => string | undefined;\n}\n\n/** Linear trailing-slash trim (avoids a backtracking regex on the base URL). */\nfunction trimTrailingSlashes(value: string): string {\n let end = value.length;\n while (end > 0 && value.charCodeAt(end - 1) === 47 /* \"/\" */) end--;\n return value.slice(0, end);\n}\n\nclass DesktopClient {\n #baseUrl: string;\n #getToken: () => string | undefined;\n\n constructor(options: DesktopClientOptions) {\n this.#baseUrl = trimTrailingSlashes(options.baseUrl);\n this.#getToken = options.getToken;\n }\n\n #headers(): Record<string, string> {\n const token = this.#getToken();\n return token ? { authorization: `Bearer ${token}` } : {};\n }\n\n async verifyAuth(signal?: AbortSignal): Promise<WearableAuthCheck> {\n let res: Response;\n try {\n res = await fetch(`${this.#baseUrl}/v1/health`, { headers: this.#headers(), signal: withTimeout(signal) });\n } catch (err) {\n // Honor caller cancellation; do not mislabel a deliberate abort.\n if (signal?.aborted) throw err;\n // Timeout / connection refused / DNS / offline: the daemon isn't\n // reachable. This is NOT an auth failure and must never throw (AC2).\n return { ok: false, detail: \"unreachable\" };\n }\n if (res.status === 401) return { ok: false, detail: \"unauthorized\" };\n if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` };\n let body: { ok?: unknown; version?: unknown };\n try {\n body = (await res.json()) as { ok?: unknown; version?: unknown };\n } catch (err) {\n // Honor caller cancellation during the body read; a non-JSON body\n // otherwise just reads as unhealthy (never throws for that, AC2).\n if (signal?.aborted) throw err;\n body = {};\n }\n if (body.ok === true) {\n return { ok: true, detail: typeof body.version === \"string\" ? `capture-audio ${body.version}` : undefined };\n }\n return { ok: false, detail: \"unhealthy\" };\n }\n\n async fetchConversations(opts: WearableFetchOptions): Promise<WearableFetchPage> {\n let res: Response;\n try {\n const url = new URL(`${this.#baseUrl}/v1/conversations`);\n url.searchParams.set(\"date\", opts.date);\n url.searchParams.set(\"timezone\", opts.timezone);\n if (opts.cursor) url.searchParams.set(\"cursor\", opts.cursor);\n res = await fetch(url, { headers: this.#headers(), signal: withTimeout(opts.signal) });\n } catch (err) {\n // Honor caller cancellation instead of reporting a daemon fault.\n if (opts.signal?.aborted) throw err;\n // Timeout, offline daemon, or a malformed configured baseUrl all\n // surface consistently as unreachable (never a raw TypeError).\n throw new DesktopDaemonError(\n `desktop capture daemon unreachable at ${this.#baseUrl} (is remnic-capture-audio running?)`,\n );\n }\n if (!res.ok) {\n // A non-2xx is a backend/auth failure, distinct from an empty day.\n throw new DesktopDaemonError(`desktop capture daemon returned HTTP ${res.status}`);\n }\n let page: ConversationPage;\n try {\n page = (await res.json()) as ConversationPage;\n } catch (err) {\n // Honor caller cancellation that lands mid-body-read.\n if (opts.signal?.aborted) throw err;\n // A 200 with an empty/non-JSON body is a backend fault, not an empty day.\n throw new DesktopDaemonError(\"desktop capture daemon returned a non-JSON conversations response\");\n }\n if (!page || !Array.isArray(page.conversations)) {\n throw new DesktopDaemonError(\"desktop capture daemon returned a malformed conversations page\");\n }\n return {\n conversations: page.conversations.map(daemonConversationToWearable),\n nextCursor: page.nextCursor ?? null,\n };\n }\n}\n\nexport function createDesktopConnector(options: WearableConnectorFactoryOptions): WearableSourceConnector {\n const baseUrl = options.settings.baseUrl?.trim() || DEFAULT_BASE_URL;\n // One client per connector, but the token is resolved per request (below)\n // so env / on-disk token rotation takes effect without a rebuild.\n let client: DesktopClient | null = null;\n const getClient = (): DesktopClient => {\n if (!client) {\n client = new DesktopClient({ baseUrl, getToken: () => resolveCaptureAudioToken(options.settings.apiKey, baseUrl) });\n }\n return client;\n };\n return {\n id: DESKTOP_SOURCE_ID,\n displayName: DESKTOP_DISPLAY_NAME,\n verifyAuth: (signal?: AbortSignal) => getClient().verifyAuth(signal),\n fetchConversations: (opts: WearableFetchOptions) => getClient().fetchConversations(opts),\n };\n}\n\nexport const wearableConnectorRegistration: WearableConnectorRegistration = {\n id: DESKTOP_SOURCE_ID,\n displayName: DESKTOP_DISPLAY_NAME,\n factory: createDesktopConnector,\n};\n\n/** Idempotently register the desktop connector with the core registry. */\nexport function ensureDesktopConnectorRegistered(): boolean {\n if (getWearableConnector(DESKTOP_SOURCE_ID) !== undefined) return false;\n registerWearableConnector(wearableConnectorRegistration);\n return true;\n}\n\nensureDesktopConnectorRegistered();\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@remnic/capture-audio",
3
+ "version": "9.24.0",
4
+ "description": "Desktop audio capture daemon for Remnic — local spool, loopback HTTP API, and replay ingestion for the wearable source `desktop` (à-la-carte)",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "remnic-capture-audio": "./dist/cli-bin.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup src/index.ts src/cli-bin.ts --format esm --dts",
22
+ "precheck-types": "node ../../scripts/ensure-bench-build-deps.mjs",
23
+ "check-types": "tsc --noEmit",
24
+ "test": "NODE_OPTIONS=\"${NODE_OPTIONS:+$NODE_OPTIONS }--conditions=remnic-source\" tsx --test src/config.test.ts src/validate.test.ts src/token.test.ts src/spool.test.ts src/replay.test.ts src/daemon.test.ts src/cli.test.ts src/stt.test.ts src/model.test.ts src/janitor.test.ts src/vad.test.ts src/dedup.test.ts src/assembly.test.ts src/diarization.test.ts src/connector.test.ts src/e2e.test.ts src/native.test.ts src/processor.test.ts src/capture.test.ts src/service.test.ts src/enroll.test.ts",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "engines": {
32
+ "node": ">=22.13.0"
33
+ },
34
+ "devDependencies": {
35
+ "@remnic/core": "workspace:*",
36
+ "@types/node": "^22.10.0",
37
+ "tsup": "^8.0.0",
38
+ "typescript": "^5.7.0",
39
+ "tsx": "^4.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@remnic/core": "^9.24.0",
43
+ "sherpa-onnx-node": "*",
44
+ "@remnic/capture-native-darwin-arm64": "^9.24.0",
45
+ "@remnic/capture-native-darwin-x64": "^9.24.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "sherpa-onnx-node": {
49
+ "optional": true
50
+ },
51
+ "@remnic/capture-native-darwin-arm64": {
52
+ "optional": true
53
+ },
54
+ "@remnic/capture-native-darwin-x64": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "license": "MIT",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "https://github.com/joshuaswarren/remnic.git",
62
+ "directory": "packages/capture-audio"
63
+ },
64
+ "keywords": [
65
+ "remnic",
66
+ "memory",
67
+ "wearable",
68
+ "desktop",
69
+ "audio",
70
+ "capture",
71
+ "transcript"
72
+ ]
73
+ }