@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,3187 @@
1
+ // openclaw-engram: Local-first memory plugin
2
+
3
+ // src/constants.ts
4
+ var CAPTURE_AUDIO_VERSION = "9.14.0";
5
+ var DEFAULT_HOST = "127.0.0.1";
6
+ var DEFAULT_PORT = 4340;
7
+ var SPOOL_SCHEMA_VERSION = 1;
8
+ var MAX_CONVERSATIONS_LIMIT = 500;
9
+ var DEFAULT_CONVERSATIONS_LIMIT = 50;
10
+
11
+ // src/errors.ts
12
+ var CaptureConfigError = class extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "CaptureConfigError";
16
+ }
17
+ };
18
+ var CaptureInputError = class extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "CaptureInputError";
22
+ }
23
+ };
24
+
25
+ // src/config.ts
26
+ import { readFileSync } from "fs";
27
+
28
+ // src/util.ts
29
+ import { randomFillSync } from "crypto";
30
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
31
+ function ulid(time = Date.now()) {
32
+ return encodeTime(time) + encodeRandom();
33
+ }
34
+ function encodeTime(time) {
35
+ if (!Number.isFinite(time) || time < 0) {
36
+ throw new Error("ulid: time must be a non-negative finite number");
37
+ }
38
+ let out = "";
39
+ let t = Math.floor(time);
40
+ for (let i = 0; i < 10; i++) {
41
+ out = CROCKFORD[t % 32] + out;
42
+ t = Math.floor(t / 32);
43
+ }
44
+ return out;
45
+ }
46
+ function encodeRandom() {
47
+ const bytes = new Uint8Array(16);
48
+ randomFillSync(bytes);
49
+ let out = "";
50
+ for (let i = 0; i < 16; i++) out += CROCKFORD[bytes[i] % 32];
51
+ return out;
52
+ }
53
+ function dateInTimezone(date, timezone) {
54
+ const parts = new Intl.DateTimeFormat("en-CA", {
55
+ timeZone: timezone,
56
+ year: "numeric",
57
+ month: "2-digit",
58
+ day: "2-digit"
59
+ }).formatToParts(date);
60
+ const get = (type) => parts.find((part) => part.type === type)?.value ?? "";
61
+ return `${get("year")}-${get("month")}-${get("day")}`;
62
+ }
63
+ var LOOPBACK_HOSTS = {
64
+ "127.0.0.1": true,
65
+ "::1": true,
66
+ localhost: true,
67
+ "::ffff:127.0.0.1": true
68
+ };
69
+ function stripIpv6Brackets(host) {
70
+ const h = host.trim();
71
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
72
+ }
73
+ function isLoopbackHost(host) {
74
+ return Object.hasOwn(LOOPBACK_HOSTS, stripIpv6Brackets(host).toLowerCase());
75
+ }
76
+ function describeValue(value) {
77
+ if (value === null) return "null";
78
+ if (Array.isArray(value)) return "an array";
79
+ const t = typeof value;
80
+ if (t === "string") return `a string`;
81
+ if (t === "object") return "an object";
82
+ return `${t} (${String(value)})`;
83
+ }
84
+ function formatHostForUrl(host) {
85
+ return host.includes(":") ? `[${host}]` : host;
86
+ }
87
+
88
+ // src/coerce.ts
89
+ function coerceNumber(value, label, bounds = {}) {
90
+ let n;
91
+ if (typeof value === "number") {
92
+ n = value;
93
+ } else if (typeof value === "string" && value.trim() !== "") {
94
+ n = Number(value);
95
+ } else {
96
+ throw new CaptureConfigError(`${label}: expected a number, got ${describeValue(value)}`);
97
+ }
98
+ if (!Number.isFinite(n)) {
99
+ throw new CaptureConfigError(`${label}: '${String(value)}' is not a finite number`);
100
+ }
101
+ if (bounds.integer && !Number.isInteger(n)) {
102
+ throw new CaptureConfigError(`${label}: expected an integer, got ${n}`);
103
+ }
104
+ if (bounds.min !== void 0 && n < bounds.min) {
105
+ throw new CaptureConfigError(`${label}: must be >= ${bounds.min}, got ${n}`);
106
+ }
107
+ if (bounds.max !== void 0 && n > bounds.max) {
108
+ throw new CaptureConfigError(`${label}: must be <= ${bounds.max}, got ${n}`);
109
+ }
110
+ return n;
111
+ }
112
+
113
+ // src/config.ts
114
+ function defaultDaemonConfig() {
115
+ return {
116
+ host: DEFAULT_HOST,
117
+ port: DEFAULT_PORT,
118
+ chunkSeconds: 30,
119
+ captureChannel: "both",
120
+ conversationGapMinutes: 10,
121
+ rawRetentionHours: 0,
122
+ spoolRetentionDays: 30,
123
+ vad: {
124
+ modelPath: null,
125
+ minSpeechMs: 500,
126
+ minSilenceMs: 500,
127
+ maxSpeechMs: 3e4,
128
+ threshold: 0.5,
129
+ threads: 1
130
+ },
131
+ diarization: { similarityThreshold: 0.4 },
132
+ stt: { engine: "whisper-cpp", modelPath: null, threads: null },
133
+ denyApps: [],
134
+ devices: { mic: null, system: null }
135
+ };
136
+ }
137
+ var KNOWN_TOP_KEYS = {
138
+ host: true,
139
+ port: true,
140
+ chunkSeconds: true,
141
+ captureChannel: true,
142
+ conversationGapMinutes: true,
143
+ rawRetentionHours: true,
144
+ spoolRetentionDays: true,
145
+ vad: true,
146
+ diarization: true,
147
+ stt: true,
148
+ denyApps: true,
149
+ devices: true
150
+ };
151
+ function asObject(value, label) {
152
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
153
+ throw new CaptureConfigError(`${label}: expected an object, got ${describeValue(value)}`);
154
+ }
155
+ return value;
156
+ }
157
+ function warnUnknownKeys(obj, known, label) {
158
+ for (const key of Object.keys(obj)) {
159
+ if (!Object.hasOwn(known, key)) {
160
+ console.warn(`remnic-capture-audio: ${label}: ignoring unknown key '${key}'`);
161
+ }
162
+ }
163
+ }
164
+ function requireString(value, label) {
165
+ if (typeof value !== "string" || value.trim() === "") {
166
+ throw new CaptureConfigError(`${label}: expected a non-empty string, got ${describeValue(value)}`);
167
+ }
168
+ return value.trim();
169
+ }
170
+ function parseDaemonConfig(raw) {
171
+ const cfg = defaultDaemonConfig();
172
+ const obj = asObject(raw, "config");
173
+ warnUnknownKeys(obj, KNOWN_TOP_KEYS, "config");
174
+ if (obj.host !== void 0) cfg.host = requireString(obj.host, "host");
175
+ if (obj.port !== void 0) {
176
+ cfg.port = coerceNumber(obj.port, "port", { integer: true, min: 1, max: 65535 });
177
+ }
178
+ if (obj.chunkSeconds !== void 0) {
179
+ cfg.chunkSeconds = coerceNumber(obj.chunkSeconds, "chunkSeconds", { integer: true, min: 1, max: 3600 });
180
+ }
181
+ if (obj.captureChannel !== void 0) {
182
+ if (obj.captureChannel !== "mic" && obj.captureChannel !== "system" && obj.captureChannel !== "both") {
183
+ throw new CaptureConfigError(
184
+ `captureChannel: expected 'mic' | 'system' | 'both', got ${describeValue(obj.captureChannel)}`
185
+ );
186
+ }
187
+ cfg.captureChannel = obj.captureChannel;
188
+ }
189
+ if (obj.conversationGapMinutes !== void 0) {
190
+ cfg.conversationGapMinutes = coerceNumber(obj.conversationGapMinutes, "conversationGapMinutes", { min: 0 });
191
+ }
192
+ if (obj.rawRetentionHours !== void 0) {
193
+ cfg.rawRetentionHours = coerceNumber(obj.rawRetentionHours, "rawRetentionHours", { min: 0 });
194
+ }
195
+ if (obj.spoolRetentionDays !== void 0) {
196
+ cfg.spoolRetentionDays = coerceNumber(obj.spoolRetentionDays, "spoolRetentionDays", { integer: true, min: 1 });
197
+ }
198
+ if (obj.vad !== void 0) {
199
+ const vad = asObject(obj.vad, "vad");
200
+ warnUnknownKeys(
201
+ vad,
202
+ {
203
+ modelPath: true,
204
+ minSpeechMs: true,
205
+ minSilenceMs: true,
206
+ maxSpeechMs: true,
207
+ threshold: true,
208
+ threads: true
209
+ },
210
+ "vad"
211
+ );
212
+ if (vad.modelPath !== void 0) {
213
+ cfg.vad.modelPath = vad.modelPath === null ? null : requireString(vad.modelPath, "vad.modelPath");
214
+ }
215
+ if (vad.minSpeechMs !== void 0) {
216
+ cfg.vad.minSpeechMs = coerceNumber(vad.minSpeechMs, "vad.minSpeechMs", { integer: true, min: 1 });
217
+ }
218
+ if (vad.minSilenceMs !== void 0) {
219
+ cfg.vad.minSilenceMs = coerceNumber(vad.minSilenceMs, "vad.minSilenceMs", { integer: true, min: 0 });
220
+ }
221
+ if (vad.maxSpeechMs !== void 0) {
222
+ cfg.vad.maxSpeechMs = coerceNumber(vad.maxSpeechMs, "vad.maxSpeechMs", { integer: true, min: 1 });
223
+ }
224
+ if (vad.threshold !== void 0) {
225
+ const threshold = coerceNumber(vad.threshold, "vad.threshold", { max: 1 });
226
+ if (threshold <= 0 || threshold >= 1) {
227
+ throw new CaptureConfigError("vad.threshold must be between 0 and 1");
228
+ }
229
+ cfg.vad.threshold = threshold;
230
+ }
231
+ if (vad.threads !== void 0) {
232
+ cfg.vad.threads = coerceNumber(vad.threads, "vad.threads", { integer: true, min: 1, max: 256 });
233
+ }
234
+ if (cfg.vad.minSpeechMs > cfg.vad.maxSpeechMs) {
235
+ throw new CaptureConfigError("vad.maxSpeechMs must be greater than or equal to vad.minSpeechMs");
236
+ }
237
+ }
238
+ if (obj.diarization !== void 0) {
239
+ const dia = asObject(obj.diarization, "diarization");
240
+ warnUnknownKeys(dia, { similarityThreshold: true }, "diarization");
241
+ if (dia.similarityThreshold !== void 0) {
242
+ const threshold = coerceNumber(dia.similarityThreshold, "diarization.similarityThreshold", { max: 1 });
243
+ if (threshold <= 0 || threshold >= 1) {
244
+ throw new CaptureConfigError("diarization.similarityThreshold must be between 0 and 1");
245
+ }
246
+ cfg.diarization.similarityThreshold = threshold;
247
+ }
248
+ }
249
+ if (obj.stt !== void 0) {
250
+ const stt = asObject(obj.stt, "stt");
251
+ warnUnknownKeys(stt, { engine: true, modelPath: true, threads: true }, "stt");
252
+ if (stt.engine !== void 0 && stt.engine !== "whisper-cpp") {
253
+ throw new CaptureConfigError(`stt.engine: only 'whisper-cpp' is supported, got ${describeValue(stt.engine)}`);
254
+ }
255
+ if (stt.modelPath !== void 0 && stt.modelPath !== null) {
256
+ if (typeof stt.modelPath !== "string") {
257
+ throw new CaptureConfigError(`stt.modelPath: expected a string, got ${describeValue(stt.modelPath)}`);
258
+ }
259
+ cfg.stt.modelPath = stt.modelPath.trim() || null;
260
+ }
261
+ if (stt.threads !== void 0 && stt.threads !== null) {
262
+ cfg.stt.threads = coerceNumber(stt.threads, "stt.threads", { integer: true, min: 1, max: 256 });
263
+ }
264
+ }
265
+ if (obj.denyApps !== void 0) {
266
+ if (!Array.isArray(obj.denyApps) || !obj.denyApps.every((app) => typeof app === "string")) {
267
+ throw new CaptureConfigError(`denyApps: expected an array of strings, got ${describeValue(obj.denyApps)}`);
268
+ }
269
+ cfg.denyApps = [...obj.denyApps];
270
+ }
271
+ if (obj.devices !== void 0) {
272
+ const dev = asObject(obj.devices, "devices");
273
+ warnUnknownKeys(dev, { mic: true, system: true }, "devices");
274
+ if (dev.mic !== void 0 && dev.mic !== null) {
275
+ if (typeof dev.mic !== "string") {
276
+ throw new CaptureConfigError(`devices.mic: expected a string, got ${describeValue(dev.mic)}`);
277
+ }
278
+ cfg.devices.mic = dev.mic;
279
+ }
280
+ if (dev.system !== void 0 && dev.system !== null) {
281
+ if (typeof dev.system !== "string") {
282
+ throw new CaptureConfigError(`devices.system: expected a string, got ${describeValue(dev.system)}`);
283
+ }
284
+ cfg.devices.system = dev.system;
285
+ }
286
+ }
287
+ return cfg;
288
+ }
289
+ function loadDaemonConfig(configPath) {
290
+ let text;
291
+ try {
292
+ text = readFileSync(configPath, "utf8");
293
+ } catch {
294
+ throw new CaptureConfigError(
295
+ `config not found at ${configPath} \u2014 run \`remnic-capture-audio init\` first`
296
+ );
297
+ }
298
+ let raw;
299
+ try {
300
+ raw = JSON.parse(text);
301
+ } catch (err) {
302
+ throw new CaptureConfigError(`config at ${configPath} is not valid JSON: ${err.message}`);
303
+ }
304
+ return parseDaemonConfig(raw);
305
+ }
306
+ function serializeDaemonConfig(cfg) {
307
+ return `${JSON.stringify(cfg, null, 2)}
308
+ `;
309
+ }
310
+
311
+ // src/control.ts
312
+ import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "fs";
313
+ import { randomBytes } from "crypto";
314
+ import path from "path";
315
+ function writePidFile(pidPath, pid, options = {}) {
316
+ mkdirSync(path.dirname(pidPath), { recursive: true });
317
+ const record = {
318
+ pid,
319
+ instanceId: options.instanceId ?? null,
320
+ startedAtIso: options.startedAtIso ?? (/* @__PURE__ */ new Date()).toISOString(),
321
+ host: options.host ?? null,
322
+ port: options.port ?? null
323
+ };
324
+ const tmp = `${pidPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
325
+ writeFileSync(tmp, `${JSON.stringify(record)}
326
+ `, "utf8");
327
+ renameSync(tmp, pidPath);
328
+ }
329
+ function readPidRecord(pidPath) {
330
+ let text;
331
+ try {
332
+ text = readFileSync2(pidPath, "utf8");
333
+ } catch {
334
+ return null;
335
+ }
336
+ let parsed;
337
+ try {
338
+ parsed = JSON.parse(text);
339
+ } catch {
340
+ return null;
341
+ }
342
+ if (typeof parsed !== "object" || parsed === null) return null;
343
+ const record = parsed;
344
+ const pid = typeof record.pid === "number" ? record.pid : Number.NaN;
345
+ if (!Number.isInteger(pid) || pid <= 0) return null;
346
+ const port = typeof record.port === "number" && Number.isInteger(record.port) && record.port > 0 ? record.port : null;
347
+ return {
348
+ pid,
349
+ instanceId: typeof record.instanceId === "string" ? record.instanceId : null,
350
+ startedAtIso: typeof record.startedAtIso === "string" ? record.startedAtIso : "",
351
+ host: typeof record.host === "string" && record.host !== "" ? record.host : null,
352
+ port
353
+ };
354
+ }
355
+ function readPidFile(pidPath) {
356
+ return readPidRecord(pidPath)?.pid ?? null;
357
+ }
358
+ function isProcessAlive(pid) {
359
+ try {
360
+ process.kill(pid, 0);
361
+ return true;
362
+ } catch (err) {
363
+ return err.code === "EPERM";
364
+ }
365
+ }
366
+ function removePidFile(pidPath) {
367
+ rmSync(pidPath, { force: true });
368
+ }
369
+ function removePidFileIfOwner(pidPath, pid) {
370
+ const record = readPidRecord(pidPath);
371
+ if (record && record.pid === pid) rmSync(pidPath, { force: true });
372
+ }
373
+
374
+ // src/token.ts
375
+ import { Buffer as Buffer2 } from "buffer";
376
+ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
377
+ import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
378
+ import path2 from "path";
379
+ function generateToken() {
380
+ return randomBytes2(32).toString("base64url");
381
+ }
382
+ function loadOrCreateToken(tokenPath) {
383
+ mkdirSync2(path2.dirname(tokenPath), { recursive: true });
384
+ if (existsSync(tokenPath)) {
385
+ chmodSync(tokenPath, 384);
386
+ const existing = readFileSync3(tokenPath, "utf8").trim();
387
+ if (existing) return existing;
388
+ }
389
+ const token = generateToken();
390
+ writeFileSync2(tokenPath, `${token}
391
+ `, { mode: 384 });
392
+ chmodSync(tokenPath, 384);
393
+ return token;
394
+ }
395
+ function tokensMatch(expected, presented) {
396
+ const a = Buffer2.from(expected, "utf8");
397
+ const b = Buffer2.from(presented, "utf8");
398
+ if (a.length !== b.length) return false;
399
+ return timingSafeEqual(a, b);
400
+ }
401
+ function bearerFromHeader(header) {
402
+ const value = Array.isArray(header) ? header[0] : header;
403
+ if (!value) return null;
404
+ const trimmed = value.trim();
405
+ if (trimmed.slice(0, 6).toLowerCase() !== "bearer") return null;
406
+ const separator = trimmed.charCodeAt(6);
407
+ if (separator !== 32 && separator !== 9) return null;
408
+ const token = trimmed.slice(6).trim();
409
+ return token || null;
410
+ }
411
+
412
+ // src/validate.ts
413
+ import { Buffer as Buffer3 } from "buffer";
414
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
415
+ function parseTranscriptDate(value) {
416
+ if (typeof value !== "string" || !DATE_RE.test(value)) {
417
+ throw new CaptureInputError(`invalid date '${value ?? ""}' \u2014 expected YYYY-MM-DD`);
418
+ }
419
+ const [year, month, day] = value.split("-").map(Number);
420
+ const dt = new Date(Date.UTC(year, month - 1, day));
421
+ dt.setUTCFullYear(year);
422
+ if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) {
423
+ throw new CaptureInputError(`invalid date '${value}' \u2014 not a real calendar date`);
424
+ }
425
+ return value;
426
+ }
427
+ function assertValidTimezone(value) {
428
+ if (typeof value !== "string" || value.trim() === "") {
429
+ throw new CaptureInputError("invalid timezone '' \u2014 expected an IANA timezone");
430
+ }
431
+ try {
432
+ new Intl.DateTimeFormat("en-CA", { timeZone: value });
433
+ } catch {
434
+ throw new CaptureInputError(`invalid timezone '${value}' \u2014 not a known IANA timezone`);
435
+ }
436
+ return value;
437
+ }
438
+ function parseLimit(value) {
439
+ if (value === null || value === void 0) return DEFAULT_CONVERSATIONS_LIMIT;
440
+ const n = Number(value);
441
+ if (value === "" || !Number.isInteger(n) || n < 1 || n > MAX_CONVERSATIONS_LIMIT) {
442
+ throw new CaptureInputError(
443
+ `invalid limit '${value}' \u2014 expected an integer between 1 and ${MAX_CONVERSATIONS_LIMIT}`
444
+ );
445
+ }
446
+ return n;
447
+ }
448
+ function encodeCursor(startedAtUtc, id) {
449
+ return Buffer3.from(JSON.stringify([startedAtUtc, id]), "utf8").toString("base64url");
450
+ }
451
+ function decodeCursor(value) {
452
+ if (value === null || value === void 0 || value === "") return null;
453
+ let parsed;
454
+ try {
455
+ parsed = JSON.parse(Buffer3.from(value, "base64url").toString("utf8"));
456
+ } catch {
457
+ throw new CaptureInputError("invalid cursor \u2014 not a recognized pagination token");
458
+ }
459
+ if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[0] === "string" && typeof parsed[1] === "string" && parsed[1] !== "" && /^\d{4}-\d{2}-\d{2}T/.test(parsed[0]) && Number.isFinite(Date.parse(parsed[0]))) {
460
+ return { startedAtUtc: parsed[0], id: parsed[1] };
461
+ }
462
+ throw new CaptureInputError("invalid cursor \u2014 not a recognized pagination token");
463
+ }
464
+
465
+ // src/daemon.ts
466
+ import http from "http";
467
+ import { Buffer as Buffer4 } from "buffer";
468
+ function sendJson(res, status, body) {
469
+ const payload = JSON.stringify(body);
470
+ res.writeHead(status, {
471
+ "content-type": "application/json; charset=utf-8",
472
+ "content-length": Buffer4.byteLength(payload)
473
+ });
474
+ res.end(payload);
475
+ }
476
+ function handleHealth(deps, res) {
477
+ sendJson(res, 200, {
478
+ ok: true,
479
+ version: CAPTURE_AUDIO_VERSION,
480
+ platform: process.platform,
481
+ capturing: typeof deps.capturing === "function" ? deps.capturing() : deps.capturing ?? false,
482
+ sttModel: deps.config.stt.modelPath,
483
+ pendingChunks: deps.spool.pendingChunkCount(),
484
+ instanceId: deps.spool.meta("instance_id"),
485
+ replayStatus: deps.spool.meta("replay_status"),
486
+ pid: process.pid
487
+ });
488
+ }
489
+ function handleConversations(deps, url, res) {
490
+ const date = parseTranscriptDate(url.searchParams.get("date"));
491
+ const timezone = assertValidTimezone(url.searchParams.get("timezone"));
492
+ const limit = parseLimit(url.searchParams.get("limit"));
493
+ const cursor = url.searchParams.get("cursor");
494
+ const page = deps.spool.queryFinalConversations({ date, timezone, cursor, limit });
495
+ sendJson(res, 200, page);
496
+ }
497
+ function handleSpeakers(deps, res) {
498
+ const speakers = deps.spool.listSpeakers().map((s) => ({ id: s.id, label: s.label, isSelf: s.isSelf }));
499
+ sendJson(res, 200, { speakers });
500
+ }
501
+ function createRequestHandler(deps) {
502
+ if (!isLoopbackHost(deps.config.host)) {
503
+ throw new CaptureConfigError(
504
+ `refusing to bind non-loopback host '${deps.config.host}': capture-audio serves plain HTTP with no TLS contract; bind a loopback address (127.0.0.1 or ::1) only`
505
+ );
506
+ }
507
+ if (!deps.token) {
508
+ throw new CaptureConfigError("daemon requires a bearer token");
509
+ }
510
+ return (req, res) => {
511
+ try {
512
+ const presented = bearerFromHeader(req.headers["authorization"]);
513
+ if (!presented || !tokensMatch(deps.token, presented)) {
514
+ sendJson(res, 401, { error: "unauthorized" });
515
+ return;
516
+ }
517
+ if (req.method !== "GET") {
518
+ sendJson(res, 405, { error: "method not allowed" });
519
+ return;
520
+ }
521
+ const url = new URL(req.url ?? "/", "http://localhost");
522
+ switch (url.pathname) {
523
+ case "/v1/health":
524
+ handleHealth(deps, res);
525
+ return;
526
+ case "/v1/conversations":
527
+ handleConversations(deps, url, res);
528
+ return;
529
+ case "/v1/speakers":
530
+ handleSpeakers(deps, res);
531
+ return;
532
+ default:
533
+ sendJson(res, 404, { error: "not found" });
534
+ }
535
+ } catch (err) {
536
+ if (err instanceof CaptureInputError) {
537
+ sendJson(res, 400, { error: err.message });
538
+ return;
539
+ }
540
+ sendJson(res, 500, { error: "internal error" });
541
+ }
542
+ };
543
+ }
544
+ function startDaemon(deps) {
545
+ return new Promise((resolve, reject) => {
546
+ let handler;
547
+ try {
548
+ handler = createRequestHandler(deps);
549
+ } catch (err) {
550
+ reject(err);
551
+ return;
552
+ }
553
+ const server = http.createServer(handler);
554
+ const onError = (err) => reject(err);
555
+ server.once("error", onError);
556
+ server.listen(deps.config.port, deps.config.host, () => {
557
+ server.removeListener("error", onError);
558
+ server.on("error", (err) => {
559
+ process.stderr.write(`capture-audio daemon server error: ${err.code ?? err.name}
560
+ `);
561
+ });
562
+ const address = server.address();
563
+ const port = typeof address === "object" && address ? address.port : deps.config.port;
564
+ const host = deps.config.host;
565
+ resolve({
566
+ server,
567
+ host,
568
+ port,
569
+ url: `http://${formatHostForUrl(host)}:${port}`,
570
+ close: () => new Promise((res2, rej2) => {
571
+ server.close((closeErr) => closeErr ? rej2(closeErr) : res2());
572
+ })
573
+ });
574
+ });
575
+ });
576
+ }
577
+
578
+ // src/paths.ts
579
+ import os from "os";
580
+ import path3 from "path";
581
+ function expandTilde(value) {
582
+ if (value === "~") return os.homedir();
583
+ if (value.startsWith("~/")) return path3.join(os.homedir(), value.slice(2));
584
+ return value;
585
+ }
586
+ function captureBaseDir(env = process.env) {
587
+ const override = env.REMNIC_CAPTURE_DIR?.trim();
588
+ if (override) return expandTilde(override);
589
+ return path3.join(os.homedir(), ".remnic", "capture");
590
+ }
591
+ function capturePaths(baseDir = captureBaseDir()) {
592
+ return {
593
+ baseDir,
594
+ configPath: path3.join(baseDir, "audio.json"),
595
+ spoolPath: path3.join(baseDir, "audio.sqlite"),
596
+ tokenPath: path3.join(baseDir, "token"),
597
+ pidPath: path3.join(baseDir, "daemon.pid"),
598
+ logPath: path3.join(baseDir, "daemon.log")
599
+ };
600
+ }
601
+
602
+ // src/replay.ts
603
+ import { createHash } from "crypto";
604
+ import { lstatSync, readdirSync, readFileSync as readFileSync4 } from "fs";
605
+ import path4 from "path";
606
+ function asObject2(value, where) {
607
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
608
+ throw new CaptureConfigError(`${where}: expected a conversation object`);
609
+ }
610
+ return value;
611
+ }
612
+ var REPLAY_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
613
+ function parseTimestamp(value, where) {
614
+ if (typeof value !== "string" || !REPLAY_INSTANT.test(value)) {
615
+ throw new CaptureConfigError(`${where}: expected an ISO instant with a Z or numeric offset`);
616
+ }
617
+ const ms = Date.parse(value);
618
+ if (!Number.isFinite(ms)) {
619
+ throw new CaptureConfigError(`${where}: expected a valid ISO timestamp`);
620
+ }
621
+ const [cy, cm, cd] = value.slice(0, 10).split("-").map(Number);
622
+ const probe = new Date(Date.UTC(cy, cm - 1, cd));
623
+ probe.setUTCFullYear(cy);
624
+ if (probe.getUTCFullYear() !== cy || probe.getUTCMonth() !== cm - 1 || probe.getUTCDate() !== cd) {
625
+ throw new CaptureConfigError(`${where}: '${value}' is not a real calendar date`);
626
+ }
627
+ return new Date(ms).toISOString();
628
+ }
629
+ function optionalString(value, where, fallback) {
630
+ if (value === void 0) return fallback;
631
+ if (typeof value !== "string") throw new CaptureConfigError(`${where}: expected a string`);
632
+ return value;
633
+ }
634
+ function parseSegment(raw, where) {
635
+ const obj = asObject2(raw, where);
636
+ if (typeof obj.text !== "string" || obj.text === "") {
637
+ throw new CaptureConfigError(`${where}.text: expected a non-empty string`);
638
+ }
639
+ const startUtc = parseTimestamp(obj.startUtc, `${where}.startUtc`);
640
+ const endUtc = parseTimestamp(obj.endUtc, `${where}.endUtc`);
641
+ if (Date.parse(endUtc) < Date.parse(startUtc)) {
642
+ throw new CaptureConfigError(`${where}: endUtc must not precede startUtc`);
643
+ }
644
+ if (obj.isWearer !== void 0 && typeof obj.isWearer !== "boolean") {
645
+ throw new CaptureConfigError(`${where}.isWearer: expected a boolean`);
646
+ }
647
+ const channel = obj.channel === void 0 ? "mic" : obj.channel;
648
+ if (typeof channel !== "string" || channel === "") {
649
+ throw new CaptureConfigError(`${where}.channel: expected a non-empty string`);
650
+ }
651
+ return {
652
+ speakerCluster: optionalString(obj.speakerCluster, `${where}.speakerCluster`, null),
653
+ isWearer: obj.isWearer === true,
654
+ channel,
655
+ text: obj.text,
656
+ startUtc,
657
+ endUtc
658
+ };
659
+ }
660
+ function parseConversation(raw, where) {
661
+ const obj = asObject2(raw, where);
662
+ const startedAtUtc = parseTimestamp(obj.startedAtUtc, `${where}.startedAtUtc`);
663
+ const endedAtUtc = obj.endedAtUtc === void 0 ? null : parseTimestamp(obj.endedAtUtc, `${where}.endedAtUtc`);
664
+ if (endedAtUtc !== null && Date.parse(endedAtUtc) < Date.parse(startedAtUtc)) {
665
+ throw new CaptureConfigError(`${where}: endedAtUtc must not precede startedAtUtc`);
666
+ }
667
+ if (obj.state !== void 0 && obj.state !== "capturing" && obj.state !== "final") {
668
+ throw new CaptureConfigError(`${where}.state: expected "capturing" or "final"`);
669
+ }
670
+ if (obj.id !== void 0 && (typeof obj.id !== "string" || obj.id === "")) {
671
+ throw new CaptureConfigError(`${where}.id: expected a non-empty string`);
672
+ }
673
+ if (!Array.isArray(obj.segments)) {
674
+ throw new CaptureConfigError(`${where}.segments: expected an array`);
675
+ }
676
+ return {
677
+ id: obj.id === void 0 ? void 0 : obj.id,
678
+ startedAtUtc,
679
+ endedAtUtc,
680
+ state: obj.state ?? "final",
681
+ device: optionalString(obj.device, `${where}.device`, null),
682
+ segments: obj.segments.map((seg, i) => parseSegment(seg, `${where}.segments[${i}]`))
683
+ };
684
+ }
685
+ function parseSpeakers(raw, where) {
686
+ if (raw === void 0) return [];
687
+ if (!Array.isArray(raw)) {
688
+ throw new CaptureConfigError(`${where}.speakers: expected an array`);
689
+ }
690
+ return raw.map((entry, i) => {
691
+ const obj = asObject2(entry, `${where}.speakers[${i}]`);
692
+ if (typeof obj.id !== "string" || obj.id === "") {
693
+ throw new CaptureConfigError(`${where}.speakers[${i}].id: expected a non-empty string`);
694
+ }
695
+ if (obj.isSelf !== void 0 && typeof obj.isSelf !== "boolean") {
696
+ throw new CaptureConfigError(`${where}.speakers[${i}].isSelf: expected a boolean`);
697
+ }
698
+ const speaker = { id: obj.id };
699
+ if (obj.label !== void 0) {
700
+ speaker.label = optionalString(obj.label, `${where}.speakers[${i}].label`, null);
701
+ }
702
+ if (obj.isSelf !== void 0) {
703
+ speaker.isSelf = obj.isSelf === true;
704
+ }
705
+ return speaker;
706
+ });
707
+ }
708
+ function listReplayFixtureFiles(dir) {
709
+ let entries;
710
+ try {
711
+ if (lstatSync(dir).isSymbolicLink()) {
712
+ throw new CaptureConfigError(`replay dir ${dir} is a symlink; refusing to follow it`);
713
+ }
714
+ entries = readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
715
+ } catch (err) {
716
+ if (err instanceof CaptureConfigError) throw err;
717
+ throw new CaptureConfigError(`replay dir not found or unreadable: ${dir}`);
718
+ }
719
+ if (entries.length === 0) {
720
+ throw new CaptureConfigError(`replay dir ${dir} contains no *.json fixtures`);
721
+ }
722
+ return entries;
723
+ }
724
+ function parseReplayFile(dir, name, seenIds, fixtures) {
725
+ const filePath = path4.join(dir, name);
726
+ if (lstatSync(filePath).isSymbolicLink()) {
727
+ throw new CaptureConfigError(`replay fixture ${name} is a symlink; refusing to follow it`);
728
+ }
729
+ let raw;
730
+ try {
731
+ raw = JSON.parse(readFileSync4(filePath, "utf8"));
732
+ } catch (err) {
733
+ throw new CaptureConfigError(`replay fixture ${name} is not valid JSON: ${err.message}`);
734
+ }
735
+ const docs = Array.isArray(raw) ? raw : [raw];
736
+ docs.forEach((doc, i) => {
737
+ const where = `${name}[${i}]`;
738
+ const conv = parseConversation(doc, where);
739
+ if (conv.id === void 0) {
740
+ const material = JSON.stringify({
741
+ startedAtUtc: conv.startedAtUtc,
742
+ endedAtUtc: conv.endedAtUtc,
743
+ state: conv.state,
744
+ segments: conv.segments
745
+ });
746
+ conv.id = `conv_${createHash("sha1").update(material).digest("hex").slice(0, 24)}`;
747
+ }
748
+ if (seenIds.has(conv.id)) {
749
+ throw new CaptureConfigError(`${where}: duplicate conversation id '${conv.id}' in this replay batch`);
750
+ }
751
+ seenIds.add(conv.id);
752
+ const speakers = parseSpeakers(asObject2(doc, where).speakers, where);
753
+ fixtures.push({ speakers, conv });
754
+ });
755
+ }
756
+ function parseReplayDir(dir) {
757
+ const entries = listReplayFixtureFiles(dir);
758
+ const fixtures = [];
759
+ const seenIds = /* @__PURE__ */ new Set();
760
+ for (const name of entries) parseReplayFile(dir, name, seenIds, fixtures);
761
+ return { fixtures, files: entries.length };
762
+ }
763
+ function commitFixture(spool, fixture, result) {
764
+ for (const speaker of fixture.speakers) spool.upsertSpeaker(speaker);
765
+ const id = spool.insertConversation(fixture.conv);
766
+ result.ids.push(id);
767
+ result.conversationsIngested += 1;
768
+ result.segmentsIngested += fixture.conv.segments.length;
769
+ }
770
+ var REPLAY_COMMIT_BATCH = 25;
771
+ function ingestReplayDir(spool, dir) {
772
+ const { fixtures, files } = parseReplayDir(dir);
773
+ const result = { files, conversationsIngested: 0, segmentsIngested: 0, ids: [], aborted: false };
774
+ for (const fixture of fixtures) commitFixture(spool, fixture, result);
775
+ return result;
776
+ }
777
+ async function ingestReplayDirResponsive(spool, dir, options = {}) {
778
+ const entries = listReplayFixtureFiles(dir);
779
+ const fixtures = [];
780
+ const seenIds = /* @__PURE__ */ new Set();
781
+ for (const name of entries) {
782
+ if (options.signal?.aborted) {
783
+ return { files: entries.length, conversationsIngested: 0, segmentsIngested: 0, ids: [], aborted: true };
784
+ }
785
+ parseReplayFile(dir, name, seenIds, fixtures);
786
+ await new Promise((resolve) => setImmediate(resolve));
787
+ }
788
+ const files = entries.length;
789
+ const result = { files, conversationsIngested: 0, segmentsIngested: 0, ids: [], aborted: false };
790
+ for (let i = 0; i < fixtures.length; i += REPLAY_COMMIT_BATCH) {
791
+ if (options.signal?.aborted) {
792
+ result.aborted = true;
793
+ break;
794
+ }
795
+ for (const fixture of fixtures.slice(i, i + REPLAY_COMMIT_BATCH)) {
796
+ commitFixture(spool, fixture, result);
797
+ }
798
+ await new Promise((resolve) => setImmediate(resolve));
799
+ }
800
+ return result;
801
+ }
802
+
803
+ // src/model.ts
804
+ import { createWriteStream, lstatSync as lstatSync2, statSync } from "fs";
805
+ import { mkdir, rename, rm } from "fs/promises";
806
+ import path5 from "path";
807
+ import { Readable } from "stream";
808
+ import { pipeline } from "stream/promises";
809
+ var MODEL_FILES = {
810
+ base: "ggml-base.bin",
811
+ small: "ggml-small.bin",
812
+ "large-v3-turbo-q5_0": "ggml-large-v3-turbo-q5_0.bin"
813
+ };
814
+ var MODEL_REPOSITORY = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
815
+ function responseBodyToReadable(body) {
816
+ const reader = body.getReader();
817
+ return new Readable({
818
+ read() {
819
+ void reader.read().then(({ done, value }) => this.push(done ? null : Buffer.from(value))).catch((error) => this.destroy(error));
820
+ }
821
+ });
822
+ }
823
+ function existingFile(destination) {
824
+ let entry;
825
+ try {
826
+ entry = statSync(destination);
827
+ } catch (error) {
828
+ if (error.code !== "ENOENT") throw error;
829
+ try {
830
+ lstatSync2(destination);
831
+ } catch (linkError) {
832
+ if (linkError.code === "ENOENT") return false;
833
+ throw linkError;
834
+ }
835
+ throw new CaptureConfigError(`Whisper model path is a broken symlink: ${destination}; remove it and retry`);
836
+ }
837
+ if (!entry.isFile()) {
838
+ throw new CaptureConfigError(`Whisper model path exists but is not a regular file: ${destination}`);
839
+ }
840
+ return true;
841
+ }
842
+ function whisperModelUrl(model) {
843
+ if (!Object.hasOwn(MODEL_FILES, model)) {
844
+ throw new CaptureConfigError(`unknown Whisper model '${model}'; expected one of ${Object.keys(MODEL_FILES).join(", ")}`);
845
+ }
846
+ const file = MODEL_FILES[model];
847
+ return `${MODEL_REPOSITORY}/${file}`;
848
+ }
849
+ async function downloadWhisperModel(input) {
850
+ const url = whisperModelUrl(input.model);
851
+ const filename = new URL(url).pathname.split("/").at(-1);
852
+ if (!filename) throw new CaptureConfigError("Whisper model URL has no filename");
853
+ await mkdir(input.directory, { recursive: true });
854
+ const destination = path5.join(input.directory, filename);
855
+ if (existingFile(destination)) return { path: destination, downloaded: false };
856
+ let response;
857
+ try {
858
+ response = await (input.fetch ?? ((value) => fetch(value)))(url);
859
+ } catch {
860
+ throw new CaptureConfigError(
861
+ `failed to download ${input.model}: the network request to Hugging Face failed (check connectivity/proxy/DNS)`
862
+ );
863
+ }
864
+ if (!response.ok || !response.body) {
865
+ throw new CaptureConfigError(`failed to download ${input.model}: HTTP ${response.status}`);
866
+ }
867
+ const temporary = path5.join(input.directory, `.${filename}.${process.pid}.${crypto.randomUUID()}.tmp`);
868
+ try {
869
+ await pipeline(responseBodyToReadable(response.body), createWriteStream(temporary, { flags: "wx", mode: 384 }));
870
+ await rename(temporary, destination);
871
+ return { path: destination, downloaded: true };
872
+ } catch (error) {
873
+ await rm(temporary, { force: true });
874
+ throw error;
875
+ }
876
+ }
877
+
878
+ // src/janitor.ts
879
+ import { lstat, readdir, rm as rm2 } from "fs/promises";
880
+ import path6 from "path";
881
+ async function pruneExpiredRawAudio(rawDirectory, retentionMs, nowMs = Date.now()) {
882
+ if (!Number.isFinite(retentionMs) || retentionMs < 0) {
883
+ throw new CaptureConfigError("raw audio retention must be a non-negative duration");
884
+ }
885
+ const cutoffMs = nowMs - retentionMs;
886
+ let root;
887
+ try {
888
+ root = await lstat(rawDirectory);
889
+ } catch (error) {
890
+ if (error.code === "ENOENT") return [];
891
+ throw error;
892
+ }
893
+ if (root.isSymbolicLink() || !root.isDirectory()) {
894
+ throw new CaptureConfigError("raw audio directory must be a non-symlink directory");
895
+ }
896
+ const entries = await readdir(rawDirectory, { withFileTypes: true });
897
+ const removed = [];
898
+ for (const entry of entries) {
899
+ if (!entry.isFile()) continue;
900
+ const location = path6.join(rawDirectory, entry.name);
901
+ try {
902
+ const stat = await lstat(location);
903
+ if (!stat.isFile()) continue;
904
+ if (stat.mtimeMs <= cutoffMs) {
905
+ await rm2(location);
906
+ removed.push(location);
907
+ }
908
+ } catch (error) {
909
+ if (error.code === "ENOENT") continue;
910
+ throw error;
911
+ }
912
+ }
913
+ return removed.sort();
914
+ }
915
+
916
+ // src/spool.ts
917
+ import { chmodSync as chmodSync2 } from "fs";
918
+ import { DatabaseSync } from "sqlite";
919
+ var SCHEMA_SQL = `
920
+ CREATE TABLE IF NOT EXISTS meta (
921
+ key TEXT PRIMARY KEY,
922
+ value TEXT NOT NULL
923
+ );
924
+ CREATE TABLE IF NOT EXISTS conversations (
925
+ id TEXT PRIMARY KEY,
926
+ started_at_utc TEXT NOT NULL,
927
+ ended_at_utc TEXT,
928
+ state TEXT NOT NULL,
929
+ segment_count INTEGER NOT NULL DEFAULT 0
930
+ );
931
+ CREATE TABLE IF NOT EXISTS chunks (
932
+ id TEXT PRIMARY KEY,
933
+ channel TEXT NOT NULL,
934
+ device TEXT,
935
+ started_at_utc TEXT NOT NULL,
936
+ ended_at_utc TEXT NOT NULL,
937
+ status TEXT NOT NULL,
938
+ wav_path TEXT
939
+ );
940
+ CREATE TABLE IF NOT EXISTS segments (
941
+ id TEXT PRIMARY KEY,
942
+ chunk_id TEXT REFERENCES chunks(id) ON DELETE CASCADE,
943
+ conversation_id TEXT REFERENCES conversations(id) ON DELETE CASCADE,
944
+ speaker_cluster TEXT,
945
+ is_wearer INTEGER NOT NULL DEFAULT 0,
946
+ channel TEXT NOT NULL,
947
+ text TEXT NOT NULL,
948
+ start_utc TEXT NOT NULL,
949
+ end_utc TEXT NOT NULL,
950
+ ordinal INTEGER NOT NULL DEFAULT 0
951
+ );
952
+ CREATE TABLE IF NOT EXISTS speaker_clusters (
953
+ id TEXT PRIMARY KEY,
954
+ label TEXT,
955
+ centroid BLOB,
956
+ example_embeddings BLOB,
957
+ embedding_count INTEGER NOT NULL DEFAULT 0,
958
+ is_self INTEGER NOT NULL DEFAULT 0
959
+ );
960
+ CREATE TABLE IF NOT EXISTS applied_chunks (
961
+ idempotency_key TEXT PRIMARY KEY,
962
+ conversation_id TEXT NOT NULL,
963
+ applied_at_utc TEXT NOT NULL
964
+ );
965
+ CREATE INDEX IF NOT EXISTS idx_conv_keyset ON conversations(started_at_utc, id);
966
+ CREATE INDEX IF NOT EXISTS idx_seg_conv ON segments(conversation_id, ordinal);
967
+ `;
968
+ var ISO_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
969
+ function assertIsoInstant(value, label) {
970
+ const match = typeof value === "string" ? ISO_INSTANT.exec(value) : null;
971
+ if (!match || !Number.isFinite(Date.parse(value))) {
972
+ throw new CaptureConfigError(
973
+ `${label}: '${value}' is not a canonical ISO instant (need date, time, and Z or offset)`
974
+ );
975
+ }
976
+ const year = Number(match[1]);
977
+ const month = Number(match[2]);
978
+ const day = Number(match[3]);
979
+ const probe = new Date(Date.UTC(year, month - 1, day));
980
+ probe.setUTCFullYear(year);
981
+ if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
982
+ throw new CaptureConfigError(`${label}: '${value}' is not a real calendar date`);
983
+ }
984
+ }
985
+ function canonicalInstant(value, label) {
986
+ assertIsoInstant(value, label);
987
+ return new Date(value).toISOString();
988
+ }
989
+ var CONVERSATION_STATES = { capturing: true, final: true };
990
+ var CHUNK_STATUSES = {
991
+ pending: true,
992
+ transcribed: true,
993
+ failed: true,
994
+ deleted: true
995
+ };
996
+ var Spool = class {
997
+ #db;
998
+ #closed = false;
999
+ constructor(location) {
1000
+ this.#db = new DatabaseSync(location);
1001
+ this.#db.exec("PRAGMA journal_mode = WAL;");
1002
+ this.#db.exec("PRAGMA foreign_keys = ON;");
1003
+ this.#db.exec("PRAGMA busy_timeout = 5000;");
1004
+ this.#db.exec(SCHEMA_SQL);
1005
+ if (location !== ":memory:") {
1006
+ try {
1007
+ chmodSync2(location, 384);
1008
+ } catch {
1009
+ }
1010
+ }
1011
+ this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("schema_version", String(SPOOL_SCHEMA_VERSION));
1012
+ this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("instance_id", ulid());
1013
+ }
1014
+ close() {
1015
+ if (this.#closed) return;
1016
+ this.#closed = true;
1017
+ this.#db.close();
1018
+ }
1019
+ meta(key) {
1020
+ const row = this.#db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
1021
+ return row?.value ?? null;
1022
+ }
1023
+ setMeta(key, value) {
1024
+ this.#db.prepare("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
1025
+ }
1026
+ /**
1027
+ * Insert (or replace) a whole conversation with its segments and a
1028
+ * backing chunk row, atomically. Idempotent by conversation id:
1029
+ * re-ingesting the same id deletes the prior rows first, so a repeated
1030
+ * replay is a content no-op (kill-9 restart safety, acceptance criteria).
1031
+ */
1032
+ insertConversation(input) {
1033
+ if (typeof input.startedAtUtc !== "string" || input.startedAtUtc.trim() === "") {
1034
+ throw new CaptureConfigError("conversation.startedAtUtc: expected a non-empty ISO timestamp");
1035
+ }
1036
+ if (!Array.isArray(input.segments)) {
1037
+ throw new CaptureConfigError("conversation.segments: expected an array");
1038
+ }
1039
+ const startedAtUtc = canonicalInstant(input.startedAtUtc, "conversation.startedAtUtc");
1040
+ const endedAtUtcInput = input.endedAtUtc !== void 0 && input.endedAtUtc !== null ? canonicalInstant(input.endedAtUtc, "conversation.endedAtUtc") : null;
1041
+ if (endedAtUtcInput !== null && Date.parse(endedAtUtcInput) < Date.parse(startedAtUtc)) {
1042
+ throw new CaptureConfigError("conversation.endedAtUtc: must not precede startedAtUtc");
1043
+ }
1044
+ if (input.state !== void 0 && !Object.hasOwn(CONVERSATION_STATES, input.state)) {
1045
+ throw new CaptureConfigError(`conversation.state: unknown value '${input.state}'`);
1046
+ }
1047
+ if (input.chunkStatus !== void 0 && !Object.hasOwn(CHUNK_STATUSES, input.chunkStatus)) {
1048
+ throw new CaptureConfigError(`conversation.chunkStatus: unknown value '${input.chunkStatus}'`);
1049
+ }
1050
+ const segments = input.segments.map((seg, i) => {
1051
+ const startUtc = canonicalInstant(seg.startUtc, `conversation.segments[${i}].startUtc`);
1052
+ const endUtc = canonicalInstant(seg.endUtc, `conversation.segments[${i}].endUtc`);
1053
+ if (Date.parse(endUtc) < Date.parse(startUtc)) {
1054
+ throw new CaptureConfigError(`conversation.segments[${i}]: endUtc must not precede startUtc`);
1055
+ }
1056
+ if (typeof seg.text !== "string" || seg.text === "") {
1057
+ throw new CaptureConfigError(`conversation.segments[${i}].text: expected a non-empty string`);
1058
+ }
1059
+ return { ...seg, startUtc, endUtc };
1060
+ });
1061
+ const convId = input.id ?? `conv_${ulid()}`;
1062
+ const chunkId = `chk_${convId}`;
1063
+ const state = input.state ?? "final";
1064
+ const chunkStatus = input.chunkStatus ?? "transcribed";
1065
+ const wavPath = input.wavPath ?? null;
1066
+ const endedAtUtc = endedAtUtcInput ?? segments[segments.length - 1]?.endUtc ?? startedAtUtc;
1067
+ const chunkChannel = segments[0]?.channel ?? "mic";
1068
+ const db = this.#db;
1069
+ db.exec("BEGIN");
1070
+ try {
1071
+ db.prepare("DELETE FROM conversations WHERE id = ?").run(convId);
1072
+ db.prepare("DELETE FROM chunks WHERE id = ?").run(chunkId);
1073
+ db.prepare(
1074
+ "INSERT INTO chunks(id, channel, device, started_at_utc, ended_at_utc, status, wav_path) VALUES (?,?,?,?,?,?,?)"
1075
+ ).run(chunkId, chunkChannel, input.device ?? null, startedAtUtc, endedAtUtc, chunkStatus, wavPath);
1076
+ db.prepare(
1077
+ "INSERT INTO conversations(id, started_at_utc, ended_at_utc, state, segment_count) VALUES (?,?,?,?,?)"
1078
+ ).run(convId, startedAtUtc, endedAtUtc, state, segments.length);
1079
+ const segStmt = db.prepare(
1080
+ "INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal) VALUES (?,?,?,?,?,?,?,?,?,?)"
1081
+ );
1082
+ for (let i = 0; i < segments.length; i++) {
1083
+ const seg = segments[i];
1084
+ segStmt.run(
1085
+ `seg_${ulid()}`,
1086
+ chunkId,
1087
+ convId,
1088
+ seg.speakerCluster ?? null,
1089
+ seg.isWearer ? 1 : 0,
1090
+ seg.channel,
1091
+ seg.text,
1092
+ seg.startUtc,
1093
+ seg.endUtc,
1094
+ i
1095
+ );
1096
+ }
1097
+ db.exec("COMMIT");
1098
+ } catch (err) {
1099
+ db.exec("ROLLBACK");
1100
+ throw err;
1101
+ }
1102
+ return convId;
1103
+ }
1104
+ /** Flip every still-open conversation to `final` (daemon stop / gap timeout). */
1105
+ finalizeOpenConversations() {
1106
+ const result = this.#db.prepare("UPDATE conversations SET state = 'final' WHERE state = 'capturing'").run();
1107
+ return Number(result.changes);
1108
+ }
1109
+ /**
1110
+ * Durably append one transcribed chunk's segments to a conversation,
1111
+ * idempotent on `idempotencyKey` (a replay/restart of the same chunk is a
1112
+ * no-op). Creates the conversation as `capturing` on first append; a later
1113
+ * `finalizeConversation`/`finalizeOpenConversations` flips it to `final`.
1114
+ */
1115
+ appendAssembledSegments(input) {
1116
+ if (typeof input.idempotencyKey !== "string" || input.idempotencyKey.trim() === "") {
1117
+ throw new CaptureConfigError("appendAssembledSegments.idempotencyKey: expected a non-empty string");
1118
+ }
1119
+ if (typeof input.conversationId !== "string" || input.conversationId.trim() === "") {
1120
+ throw new CaptureConfigError("appendAssembledSegments.conversationId: expected a non-empty string");
1121
+ }
1122
+ if (typeof input.startedAtUtc !== "string" || input.startedAtUtc.trim() === "") {
1123
+ throw new CaptureConfigError("appendAssembledSegments.startedAtUtc: expected a non-empty ISO timestamp");
1124
+ }
1125
+ if (!Array.isArray(input.segments) || input.segments.length === 0) {
1126
+ throw new CaptureConfigError("appendAssembledSegments.segments: expected a non-empty array");
1127
+ }
1128
+ if (input.state !== void 0 && !Object.hasOwn(CONVERSATION_STATES, input.state)) {
1129
+ throw new CaptureConfigError(`appendAssembledSegments.state: unknown value '${input.state}'`);
1130
+ }
1131
+ const startedAtUtc = canonicalInstant(input.startedAtUtc, "appendAssembledSegments.startedAtUtc");
1132
+ const segments = input.segments.map((seg, i) => {
1133
+ const startUtc = canonicalInstant(seg.startUtc, `appendAssembledSegments.segments[${i}].startUtc`);
1134
+ const endUtc = canonicalInstant(seg.endUtc, `appendAssembledSegments.segments[${i}].endUtc`);
1135
+ if (Date.parse(endUtc) < Date.parse(startUtc)) {
1136
+ throw new CaptureConfigError(`appendAssembledSegments.segments[${i}]: endUtc must not precede startUtc`);
1137
+ }
1138
+ if (typeof seg.text !== "string" || seg.text === "") {
1139
+ throw new CaptureConfigError(`appendAssembledSegments.segments[${i}].text: expected a non-empty string`);
1140
+ }
1141
+ return { ...seg, startUtc, endUtc };
1142
+ });
1143
+ const convId = input.conversationId;
1144
+ const chunkId = input.chunkId ?? input.idempotencyKey;
1145
+ const state = input.state ?? "capturing";
1146
+ const chunkChannel = segments[0]?.channel ?? "mic";
1147
+ const lastEnd = segments[segments.length - 1].endUtc;
1148
+ const chunkStart = segments[0].startUtc;
1149
+ const db = this.#db;
1150
+ db.exec("BEGIN");
1151
+ try {
1152
+ const seen = db.prepare("SELECT conversation_id AS conversationId FROM applied_chunks WHERE idempotency_key = ?").get(input.idempotencyKey);
1153
+ if (seen) {
1154
+ db.exec("COMMIT");
1155
+ return { applied: false, conversationId: seen.conversationId, segmentCount: this.#segmentCount(seen.conversationId) };
1156
+ }
1157
+ db.prepare(
1158
+ "INSERT OR IGNORE INTO chunks(id, channel, device, started_at_utc, ended_at_utc, status, wav_path) VALUES (?,?,?,?,?,?,?)"
1159
+ ).run(chunkId, chunkChannel, input.device ?? null, chunkStart, lastEnd, "transcribed", input.wavPath ?? null);
1160
+ const existing = db.prepare("SELECT id FROM conversations WHERE id = ?").get(convId);
1161
+ if (!existing) {
1162
+ db.prepare(
1163
+ "INSERT INTO conversations(id, started_at_utc, ended_at_utc, state, segment_count) VALUES (?,?,?,?,0)"
1164
+ ).run(convId, startedAtUtc, lastEnd, state);
1165
+ }
1166
+ const ordinalRow = db.prepare("SELECT COALESCE(MAX(ordinal), -1) + 1 AS n FROM segments WHERE conversation_id = ?").get(convId);
1167
+ const nextOrdinal = Number(ordinalRow.n);
1168
+ const segStmt = db.prepare(
1169
+ "INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal) VALUES (?,?,?,?,?,?,?,?,?,?)"
1170
+ );
1171
+ for (let i = 0; i < segments.length; i++) {
1172
+ const seg = segments[i];
1173
+ segStmt.run(
1174
+ `seg_${ulid()}`,
1175
+ chunkId,
1176
+ convId,
1177
+ seg.speakerCluster ?? null,
1178
+ seg.isWearer ? 1 : 0,
1179
+ seg.channel,
1180
+ seg.text,
1181
+ seg.startUtc,
1182
+ seg.endUtc,
1183
+ nextOrdinal + i
1184
+ );
1185
+ }
1186
+ db.prepare(
1187
+ "UPDATE conversations SET segment_count = segment_count + ?, ended_at_utc = CASE WHEN ended_at_utc IS NULL OR ? > ended_at_utc THEN ? ELSE ended_at_utc END WHERE id = ?"
1188
+ ).run(segments.length, lastEnd, lastEnd, convId);
1189
+ db.prepare("INSERT INTO applied_chunks(idempotency_key, conversation_id, applied_at_utc) VALUES (?,?,?)").run(
1190
+ input.idempotencyKey,
1191
+ convId,
1192
+ (/* @__PURE__ */ new Date()).toISOString()
1193
+ );
1194
+ db.exec("COMMIT");
1195
+ } catch (err) {
1196
+ db.exec("ROLLBACK");
1197
+ throw err;
1198
+ }
1199
+ return { applied: true, conversationId: convId, segmentCount: this.#segmentCount(convId) };
1200
+ }
1201
+ /** Flip one conversation to `final`; returns true when it was capturing. */
1202
+ finalizeConversation(id) {
1203
+ const result = this.#db.prepare("UPDATE conversations SET state = 'final' WHERE id = ? AND state = 'capturing'").run(id);
1204
+ return Number(result.changes) > 0;
1205
+ }
1206
+ /**
1207
+ * A conversation's segments in the shape cross-channel dedup needs (segment
1208
+ * id + DedupSegment fields), chronological. Used to prune loopback duplicates
1209
+ * at finalization, which is order-independent (all segments are present).
1210
+ */
1211
+ conversationSegmentsForDedup(conversationId) {
1212
+ return this.#db.prepare(
1213
+ "SELECT id, channel, text, start_utc AS startUtc, end_utc AS endUtc FROM segments WHERE conversation_id = ? ORDER BY start_utc ASC, ordinal ASC, id ASC"
1214
+ ).all(conversationId);
1215
+ }
1216
+ /**
1217
+ * Delete specific segments (dedup prune), keeping each owning conversation's
1218
+ * segment_count in sync. Returns the number actually removed.
1219
+ */
1220
+ deleteSegments(ids) {
1221
+ if (ids.length === 0) return 0;
1222
+ const db = this.#db;
1223
+ let removed = 0;
1224
+ db.exec("BEGIN");
1225
+ try {
1226
+ const findConv = db.prepare("SELECT conversation_id AS conversationId FROM segments WHERE id = ?");
1227
+ const del = db.prepare("DELETE FROM segments WHERE id = ?");
1228
+ const dec = db.prepare("UPDATE conversations SET segment_count = MAX(segment_count - 1, 0) WHERE id = ?");
1229
+ const affected = /* @__PURE__ */ new Set();
1230
+ for (const id of ids) {
1231
+ const row = findConv.get(id);
1232
+ if (!row) continue;
1233
+ if (Number(del.run(id).changes) > 0) {
1234
+ dec.run(row.conversationId);
1235
+ affected.add(row.conversationId);
1236
+ removed++;
1237
+ }
1238
+ }
1239
+ const bounds = db.prepare(
1240
+ "SELECT MIN(start_utc) AS minStart, MAX(end_utc) AS maxEnd FROM segments WHERE conversation_id = ?"
1241
+ );
1242
+ const setBounds = db.prepare("UPDATE conversations SET started_at_utc = ?, ended_at_utc = ? WHERE id = ?");
1243
+ for (const convId of affected) {
1244
+ const b = bounds.get(convId);
1245
+ if (b.minStart !== null && b.maxEnd !== null) setBounds.run(b.minStart, b.maxEnd, convId);
1246
+ }
1247
+ db.exec("COMMIT");
1248
+ } catch (err) {
1249
+ db.exec("ROLLBACK");
1250
+ throw err;
1251
+ }
1252
+ return removed;
1253
+ }
1254
+ /** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */
1255
+ capturingConversationIds() {
1256
+ const rows = this.#db.prepare("SELECT id FROM conversations WHERE state = 'capturing' ORDER BY id ASC").all();
1257
+ return rows.map((r) => r.id);
1258
+ }
1259
+ /** Whether a chunk with this idempotency key was already durably applied. */
1260
+ isChunkApplied(idempotencyKey) {
1261
+ return this.#db.prepare("SELECT 1 FROM applied_chunks WHERE idempotency_key = ? LIMIT 1").get(idempotencyKey) !== void 0;
1262
+ }
1263
+ /**
1264
+ * Record that a whole chunk finished (every group appended) via a `<id>:done`
1265
+ * marker, so a later full replay can skip transcription + diarization. A crash
1266
+ * before this leaves no marker, so the missing groups re-append on replay.
1267
+ */
1268
+ markChunkComplete(chunkId, conversationId) {
1269
+ this.#db.prepare("INSERT OR IGNORE INTO applied_chunks(idempotency_key, conversation_id, applied_at_utc) VALUES (?,?,?)").run(`${chunkId}:done`, conversationId, (/* @__PURE__ */ new Date()).toISOString());
1270
+ }
1271
+ /**
1272
+ * The newest still-`capturing` conversation, so a chunk arriving after a
1273
+ * process restart continues it (subject to the assembler's gap rule) instead
1274
+ * of splitting off a new one. Null when none is open.
1275
+ */
1276
+ latestCapturingConversation() {
1277
+ const row = this.#db.prepare(
1278
+ "SELECT id, started_at_utc AS startedAtUtc, ended_at_utc AS endedAtUtc FROM conversations WHERE state = 'capturing' ORDER BY ended_at_utc DESC, id DESC LIMIT 1"
1279
+ ).get();
1280
+ if (!row) return null;
1281
+ return { id: row.id, startedAtUtc: row.startedAtUtc, endedAtUtc: row.endedAtUtc ?? row.startedAtUtc };
1282
+ }
1283
+ #segmentCount(conversationId) {
1284
+ const row = this.#db.prepare("SELECT segment_count AS n FROM conversations WHERE id = ?").get(conversationId);
1285
+ return row ? Number(row.n) : 0;
1286
+ }
1287
+ upsertSpeaker(input) {
1288
+ const current = this.#db.prepare(
1289
+ "SELECT label, embedding_count AS embeddingCount, is_self AS isSelf, centroid, example_embeddings AS examples FROM speaker_clusters WHERE id = ?"
1290
+ ).get(input.id);
1291
+ const label = Object.hasOwn(input, "label") ? input.label ?? null : current?.label ?? null;
1292
+ const embeddingCount = Object.hasOwn(input, "embeddingCount") ? input.embeddingCount ?? 0 : current?.embeddingCount ?? 0;
1293
+ const isSelf = Object.hasOwn(input, "isSelf") ? input.isSelf ? 1 : 0 : current?.isSelf ?? 0;
1294
+ const centroid = Object.hasOwn(input, "centroid") ? input.centroid ? Buffer.from(JSON.stringify(input.centroid)) : null : current?.centroid ?? null;
1295
+ const examples = Object.hasOwn(input, "examples") ? input.examples ? Buffer.from(JSON.stringify(input.examples)) : null : current?.examples ?? null;
1296
+ this.#db.prepare(
1297
+ "INSERT INTO speaker_clusters(id, label, embedding_count, is_self, centroid, example_embeddings) VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET label = excluded.label, is_self = excluded.is_self, embedding_count = excluded.embedding_count, centroid = excluded.centroid, example_embeddings = excluded.example_embeddings"
1298
+ ).run(input.id, label, embeddingCount, isSelf, centroid, examples);
1299
+ }
1300
+ /** Read every speaker cluster with decoded centroid + examples (diarization restart seed). */
1301
+ readSpeakerClusters() {
1302
+ const rows = this.#db.prepare(
1303
+ "SELECT id, label, is_self AS isSelf, embedding_count AS embeddingCount, centroid, example_embeddings AS examples FROM speaker_clusters ORDER BY id ASC"
1304
+ ).all();
1305
+ const decode = (blob) => {
1306
+ if (!blob || blob.byteLength === 0) return null;
1307
+ try {
1308
+ return JSON.parse(Buffer.from(blob).toString("utf8"));
1309
+ } catch {
1310
+ return null;
1311
+ }
1312
+ };
1313
+ return rows.map((r) => {
1314
+ const centroid = decode(r.centroid);
1315
+ const examples = decode(r.examples);
1316
+ return {
1317
+ id: r.id,
1318
+ label: r.label,
1319
+ isSelf: r.isSelf === 1,
1320
+ embeddingCount: r.embeddingCount,
1321
+ centroid: Array.isArray(centroid) ? centroid : [],
1322
+ examples: Array.isArray(examples) ? examples : []
1323
+ };
1324
+ });
1325
+ }
1326
+ listSpeakers() {
1327
+ const rows = this.#db.prepare("SELECT id, label, is_self AS isSelf, embedding_count AS embeddingCount FROM speaker_clusters ORDER BY id ASC").all();
1328
+ return rows.map((r) => ({ id: r.id, label: r.label, isSelf: r.isSelf === 1, embeddingCount: r.embeddingCount }));
1329
+ }
1330
+ pendingChunkCount() {
1331
+ const row = this.#db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE status = 'pending'").get();
1332
+ return row.n;
1333
+ }
1334
+ stats() {
1335
+ const count = (table) => this.#db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
1336
+ return { conversations: count("conversations"), segments: count("segments"), chunks: count("chunks") };
1337
+ }
1338
+ getConversation(id) {
1339
+ const row = this.#db.prepare(
1340
+ "SELECT id, started_at_utc AS startedAtUtc, ended_at_utc AS endedAtUtc, state, segment_count AS segmentCount FROM conversations WHERE id = ?"
1341
+ ).get(id);
1342
+ return row ? this.#hydrate(row) : null;
1343
+ }
1344
+ /**
1345
+ * Final conversations whose local day (per `timezone`) equals `date`,
1346
+ * paged by the stable (started_at_utc, id) keyset. Fetches all final
1347
+ * rows after the cursor (the spool is a bounded buffer, not an archive),
1348
+ * filters to the requested local day, then pages — so the id tiebreak
1349
+ * keeps pagination correct across duplicate start timestamps.
1350
+ */
1351
+ queryFinalConversations(opts) {
1352
+ const cursor = decodeCursor(opts.cursor ?? null);
1353
+ const afterStarted = cursor ? cursor.startedAtUtc : "";
1354
+ const afterId = cursor ? cursor.id : "";
1355
+ const rows = this.#db.prepare(
1356
+ "SELECT id, started_at_utc AS startedAtUtc, ended_at_utc AS endedAtUtc, state, segment_count AS segmentCount FROM conversations WHERE state = 'final' AND (started_at_utc > ? OR (started_at_utc = ? AND id > ?)) ORDER BY started_at_utc ASC, id ASC"
1357
+ ).all(afterStarted, afterStarted, afterId);
1358
+ const matches = [];
1359
+ for (const row of rows) {
1360
+ if (dateInTimezone(new Date(row.startedAtUtc), opts.timezone) === opts.date) {
1361
+ matches.push(row);
1362
+ if (matches.length > opts.limit) break;
1363
+ }
1364
+ }
1365
+ const hasMore = matches.length > opts.limit;
1366
+ const page = hasMore ? matches.slice(0, opts.limit) : matches;
1367
+ const last = page[page.length - 1];
1368
+ return {
1369
+ conversations: page.map((row) => this.#hydrate(row)),
1370
+ nextCursor: hasMore && last ? encodeCursor(last.startedAtUtc, last.id) : null
1371
+ };
1372
+ }
1373
+ #hydrate(row) {
1374
+ const segs = this.#db.prepare(
1375
+ "SELECT text, speaker_cluster AS speakerKey, is_wearer AS isWearer, channel, start_utc AS startUtc, end_utc AS endUtc FROM segments WHERE conversation_id = ? ORDER BY start_utc ASC, ordinal ASC, id ASC"
1376
+ ).all(row.id);
1377
+ return {
1378
+ id: row.id,
1379
+ startedAtUtc: row.startedAtUtc,
1380
+ endedAtUtc: row.endedAtUtc,
1381
+ state: row.state,
1382
+ segmentCount: row.segmentCount,
1383
+ segments: segs.map((s) => ({
1384
+ textRaw: s.text,
1385
+ speakerKey: s.speakerKey,
1386
+ isWearer: s.isWearer === 1,
1387
+ channel: s.channel,
1388
+ startUtc: s.startUtc,
1389
+ endUtc: s.endUtc
1390
+ }))
1391
+ };
1392
+ }
1393
+ };
1394
+
1395
+ // src/assembly.ts
1396
+ function assembleConversations(segments, gapMinutes, state = "final") {
1397
+ if (!Number.isFinite(gapMinutes) || gapMinutes < 0) {
1398
+ throw new Error("assembleConversations: gapMinutes must be a non-negative number");
1399
+ }
1400
+ const gapMs = gapMinutes * 6e4;
1401
+ const conversations = [];
1402
+ let current = [];
1403
+ let prevEndMs = Number.NaN;
1404
+ const flush = () => {
1405
+ if (current.length === 0) return;
1406
+ const startedAtUtc = current[0].startUtc;
1407
+ const endedAtUtc = current[current.length - 1].endUtc;
1408
+ conversations.push({ startedAtUtc, endedAtUtc, state, segments: current });
1409
+ current = [];
1410
+ };
1411
+ for (const seg of segments) {
1412
+ const startMs = Date.parse(seg.startUtc);
1413
+ if (current.length > 0 && Number.isFinite(prevEndMs) && Number.isFinite(startMs) && startMs - prevEndMs >= gapMs) {
1414
+ flush();
1415
+ }
1416
+ current.push(seg);
1417
+ const endMs = Date.parse(seg.endUtc);
1418
+ prevEndMs = Number.isFinite(endMs) ? endMs : startMs;
1419
+ }
1420
+ flush();
1421
+ return conversations;
1422
+ }
1423
+ var DEFAULT_CONVERSATION_GAP_MINUTES = 10;
1424
+ function epochMs(value, field) {
1425
+ const ms = Date.parse(value);
1426
+ if (!Number.isFinite(ms)) {
1427
+ throw new CaptureInputError(`segment.${field}: expected an ISO-8601 timestamp`);
1428
+ }
1429
+ return ms;
1430
+ }
1431
+ var ConversationAssembler = class {
1432
+ #gapMs;
1433
+ #makeId;
1434
+ #conversations = [];
1435
+ constructor(options = {}) {
1436
+ const gapMinutes = options.gapMinutes ?? DEFAULT_CONVERSATION_GAP_MINUTES;
1437
+ if (!Number.isFinite(gapMinutes) || gapMinutes < 0) {
1438
+ throw new CaptureConfigError("conversationGapMinutes must be a non-negative number");
1439
+ }
1440
+ this.#gapMs = gapMinutes * 6e4;
1441
+ this.#makeId = options.makeId ?? (() => `conv_${ulid()}`);
1442
+ }
1443
+ /**
1444
+ * Append one segment, returning the conversation it landed in. Segments
1445
+ * arrive in non-decreasing start order; a gap of at least the threshold
1446
+ * closes the open conversation and starts a new one.
1447
+ */
1448
+ add(segment) {
1449
+ const startMs = epochMs(segment.startUtc, "startUtc");
1450
+ epochMs(segment.endUtc, "endUtc");
1451
+ const open = this.#open();
1452
+ if (open) {
1453
+ const lastEnd = epochMs(open.endedAtUtc, "endedAtUtc");
1454
+ if (startMs - lastEnd >= this.#gapMs) {
1455
+ open.state = "final";
1456
+ } else {
1457
+ open.segments.push(segment);
1458
+ if (segment.endUtc > open.endedAtUtc) open.endedAtUtc = segment.endUtc;
1459
+ return open;
1460
+ }
1461
+ }
1462
+ return this.#start(segment);
1463
+ }
1464
+ /** Flip every open (`capturing`) conversation to `final`; returns the count changed. */
1465
+ finalize() {
1466
+ let changed = 0;
1467
+ for (const conv of this.#conversations) {
1468
+ if (conv.state === "capturing") {
1469
+ conv.state = "final";
1470
+ changed++;
1471
+ }
1472
+ }
1473
+ return changed;
1474
+ }
1475
+ /**
1476
+ * Re-open a conversation recovered from durable storage so a chunk arriving
1477
+ * after a process restart continues it (subject to the same gap rule via
1478
+ * `add`) instead of splitting off a new one. No-op when a conversation is
1479
+ * already open in this run.
1480
+ */
1481
+ resume(conversation) {
1482
+ if (this.#open()) return;
1483
+ epochMs(conversation.endedAtUtc, "endedAtUtc");
1484
+ this.#conversations.push({
1485
+ id: conversation.id,
1486
+ startedAtUtc: conversation.startedAtUtc,
1487
+ endedAtUtc: conversation.endedAtUtc,
1488
+ state: "capturing",
1489
+ segments: []
1490
+ });
1491
+ }
1492
+ /** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */
1493
+ conversations() {
1494
+ return this.#conversations.map((conv) => ({ ...conv, segments: conv.segments.slice() }));
1495
+ }
1496
+ /**
1497
+ * Finalize the open conversation when `nowUtc` is at least the gap past its
1498
+ * last segment, so a run of silent chunks (which carry no segments to `add`)
1499
+ * still closes a conversation instead of leaving it `capturing` until stop.
1500
+ * Returns the closed conversation's id, or null when nothing closed.
1501
+ */
1502
+ closeIfIdle(nowUtc) {
1503
+ const open = this.#open();
1504
+ if (!open) return null;
1505
+ if (epochMs(nowUtc, "nowUtc") - epochMs(open.endedAtUtc, "endedAtUtc") < this.#gapMs) return null;
1506
+ open.state = "final";
1507
+ return open.id;
1508
+ }
1509
+ #open() {
1510
+ const last = this.#conversations[this.#conversations.length - 1];
1511
+ return last && last.state === "capturing" ? last : void 0;
1512
+ }
1513
+ #start(segment) {
1514
+ const conv = {
1515
+ id: this.#makeId(),
1516
+ startedAtUtc: segment.startUtc,
1517
+ endedAtUtc: segment.endUtc,
1518
+ state: "capturing",
1519
+ segments: [segment]
1520
+ };
1521
+ this.#conversations.push(conv);
1522
+ return conv;
1523
+ }
1524
+ };
1525
+
1526
+ // src/diarization.ts
1527
+ var MAX_EXAMPLES = 10;
1528
+ var SELF_ID = "self";
1529
+ function cosineSimilarity(a, b) {
1530
+ if (a.length === 0 || a.length !== b.length) return 0;
1531
+ let dot = 0;
1532
+ let normA = 0;
1533
+ let normB = 0;
1534
+ for (let i = 0; i < a.length; i++) {
1535
+ dot += a[i] * b[i];
1536
+ normA += a[i] * a[i];
1537
+ normB += b[i] * b[i];
1538
+ }
1539
+ if (normA === 0 || normB === 0) return 0;
1540
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
1541
+ }
1542
+ var SpeakerClusterer = class {
1543
+ #clusters = [];
1544
+ #threshold;
1545
+ #next = 1;
1546
+ constructor(threshold, seed = []) {
1547
+ if (!Number.isFinite(threshold) || threshold <= 0 || threshold >= 1) {
1548
+ throw new CaptureConfigError("diarization.similarityThreshold must be between 0 and 1");
1549
+ }
1550
+ this.#threshold = threshold;
1551
+ for (const c of seed) {
1552
+ this.#clusters.push({
1553
+ id: c.id,
1554
+ centroid: [...c.centroid],
1555
+ examples: c.examples.map((e) => [...e]),
1556
+ embeddingCount: c.embeddingCount,
1557
+ isSelf: c.isSelf,
1558
+ label: c.label
1559
+ });
1560
+ const n = /^spk_(\d+)$/.exec(c.id);
1561
+ if (n) this.#next = Math.max(this.#next, Number(n[1]) + 1);
1562
+ }
1563
+ }
1564
+ /** Register an enrolled self profile (its embedding seeds the `self` cluster). */
1565
+ enrollSelf(embedding) {
1566
+ const existing = this.#clusters.find((c) => c.id === SELF_ID);
1567
+ if (existing) {
1568
+ this.#update(existing, embedding);
1569
+ existing.isSelf = true;
1570
+ return;
1571
+ }
1572
+ this.#clusters.unshift({
1573
+ id: SELF_ID,
1574
+ centroid: [...embedding],
1575
+ examples: [[...embedding]],
1576
+ embeddingCount: 1,
1577
+ isSelf: true,
1578
+ label: null
1579
+ });
1580
+ }
1581
+ /** Best cosine over a cluster's centroid + examples. */
1582
+ #score(cluster, embedding) {
1583
+ let best = cosineSimilarity(cluster.centroid, embedding);
1584
+ for (const ex of cluster.examples) {
1585
+ const s = cosineSimilarity(ex, embedding);
1586
+ if (s > best) best = s;
1587
+ }
1588
+ return best;
1589
+ }
1590
+ #update(cluster, embedding) {
1591
+ const n = cluster.embeddingCount;
1592
+ for (let i = 0; i < cluster.centroid.length && i < embedding.length; i++) {
1593
+ cluster.centroid[i] = (cluster.centroid[i] * n + embedding[i]) / (n + 1);
1594
+ }
1595
+ cluster.embeddingCount = n + 1;
1596
+ if (cluster.examples.length < MAX_EXAMPLES) {
1597
+ cluster.examples.push([...embedding]);
1598
+ } else {
1599
+ let mostSimilar = 0;
1600
+ let mostSimilarScore = -Infinity;
1601
+ for (let i = 0; i < cluster.examples.length; i++) {
1602
+ const s = cosineSimilarity(cluster.examples[i], embedding);
1603
+ if (s > mostSimilarScore) {
1604
+ mostSimilarScore = s;
1605
+ mostSimilar = i;
1606
+ }
1607
+ }
1608
+ cluster.examples[mostSimilar] = [...embedding];
1609
+ }
1610
+ }
1611
+ /** Match `embedding` to an existing cluster or create a new `spk_<n>`. */
1612
+ assign(embedding) {
1613
+ let best = null;
1614
+ let bestScore = -Infinity;
1615
+ for (const cluster2 of this.#clusters) {
1616
+ const s = this.#score(cluster2, embedding);
1617
+ if (s > bestScore) {
1618
+ bestScore = s;
1619
+ best = cluster2;
1620
+ }
1621
+ }
1622
+ if (best && bestScore >= this.#threshold) {
1623
+ this.#update(best, embedding);
1624
+ return best.id;
1625
+ }
1626
+ const cluster = {
1627
+ id: `spk_${this.#next++}`,
1628
+ centroid: [...embedding],
1629
+ examples: [[...embedding]],
1630
+ embeddingCount: 1,
1631
+ isSelf: false,
1632
+ label: null
1633
+ };
1634
+ this.#clusters.push(cluster);
1635
+ return cluster.id;
1636
+ }
1637
+ /** Snapshot for persistence. */
1638
+ clusters() {
1639
+ return this.#clusters.map((c) => ({
1640
+ id: c.id,
1641
+ centroid: [...c.centroid],
1642
+ examples: c.examples.map((e) => [...e]),
1643
+ embeddingCount: c.embeddingCount,
1644
+ isSelf: c.isSelf,
1645
+ label: c.label
1646
+ }));
1647
+ }
1648
+ };
1649
+
1650
+ // src/native.ts
1651
+ import { spawn as nodeSpawn } from "child_process";
1652
+ import { readFileSync as readFileSync5 } from "fs";
1653
+ import { fileURLToPath } from "url";
1654
+ import path7 from "path";
1655
+ var HELPER_BIN_ENV = "REMNIC_CAPTURE_HELPER_BIN";
1656
+ var STOP_DRAIN_TIMEOUT_MS = 5e3;
1657
+ var HELPER_BIN_NAME = "remnic-capture-helper";
1658
+ function helperPackageSpecifier(platform, arch) {
1659
+ if (platform !== "darwin") {
1660
+ throw new CaptureConfigError(
1661
+ `Desktop audio capture native helper is only available on macOS; platform "${platform}" is unsupported`
1662
+ );
1663
+ }
1664
+ if (arch === "arm64") return "@remnic/capture-native-darwin-arm64";
1665
+ if (arch === "x64") return "@remnic/capture-native-darwin-x64";
1666
+ throw new CaptureConfigError(
1667
+ `Desktop audio capture native helper is unavailable for macOS architecture "${arch}"`
1668
+ );
1669
+ }
1670
+ function defaultResolve(specifier) {
1671
+ return fileURLToPath(import.meta.resolve(specifier));
1672
+ }
1673
+ function resolveHelperBinary(deps = {}) {
1674
+ const env = deps.env ?? process.env;
1675
+ const override = env[HELPER_BIN_ENV]?.trim();
1676
+ if (override) return { specifier: `(${HELPER_BIN_ENV})`, binaryPath: expandTilde(override) };
1677
+ const platform = deps.platform ?? process.platform;
1678
+ const arch = deps.arch ?? process.arch;
1679
+ const resolve = deps.resolve ?? defaultResolve;
1680
+ const readFile = deps.readFile ?? ((file) => readFileSync5(file, "utf8"));
1681
+ const specifier = helperPackageSpecifier(platform, arch);
1682
+ let entry;
1683
+ try {
1684
+ entry = resolve(specifier);
1685
+ } catch {
1686
+ throw new CaptureConfigError(
1687
+ `Desktop audio capture requires the optional native helper ${specifier}, which is not available. Build the Swift helper from source (packages/capture-native-darwin-helper) and set ${HELPER_BIN_ENV} to the built remnic-capture-helper binary.`
1688
+ );
1689
+ }
1690
+ const pkgJsonPath = path7.join(path7.dirname(entry), "package.json");
1691
+ return { specifier, binaryPath: helperBinaryFromPackage(pkgJsonPath, readFile, specifier) };
1692
+ }
1693
+ function helperBinaryFromPackage(pkgJsonPath, readFile, specifier) {
1694
+ let pkg;
1695
+ try {
1696
+ pkg = JSON.parse(readFile(pkgJsonPath));
1697
+ } catch {
1698
+ throw new CaptureConfigError(`native helper ${specifier} has an unreadable package.json at ${pkgJsonPath}`);
1699
+ }
1700
+ let bin;
1701
+ if (pkg !== null && typeof pkg === "object" && "bin" in pkg) bin = pkg.bin;
1702
+ let rel;
1703
+ if (typeof bin === "string") {
1704
+ rel = bin;
1705
+ } else if (bin !== null && typeof bin === "object") {
1706
+ const map = bin;
1707
+ const named = map[HELPER_BIN_NAME];
1708
+ const first = Object.values(map).find((v) => typeof v === "string");
1709
+ if (typeof named === "string") rel = named;
1710
+ else if (typeof first === "string") rel = first;
1711
+ }
1712
+ if (rel === void 0 || rel === "") {
1713
+ throw new CaptureConfigError(`native helper ${specifier} does not declare an executable in its package.json "bin"`);
1714
+ }
1715
+ return path7.resolve(path7.dirname(pkgJsonPath), rel);
1716
+ }
1717
+ function buildHelperArgs(opts) {
1718
+ const channel = opts.channel ?? "both";
1719
+ const args = [
1720
+ "audio-capture",
1721
+ "--channel",
1722
+ channel,
1723
+ "--chunk-seconds",
1724
+ String(opts.chunkSeconds),
1725
+ "--out",
1726
+ opts.outDir
1727
+ ];
1728
+ const device = opts.device;
1729
+ if (typeof device === "string" && device !== "") args.push("--device", device);
1730
+ return args;
1731
+ }
1732
+ var ISO_PREFIX_RE = /^\d{4}-\d{2}-\d{2}T/;
1733
+ function parseTimestamp2(value, where) {
1734
+ if (typeof value !== "string" || !ISO_PREFIX_RE.test(value)) {
1735
+ throw new CaptureInputError(`${where}: expected an ISO timestamp`);
1736
+ }
1737
+ if (!Number.isFinite(Date.parse(value))) {
1738
+ throw new CaptureInputError(`${where}: expected a valid ISO timestamp`);
1739
+ }
1740
+ return value;
1741
+ }
1742
+ function parseChunkEvent(line) {
1743
+ let raw;
1744
+ try {
1745
+ raw = JSON.parse(line);
1746
+ } catch {
1747
+ const trimmed = line.trim();
1748
+ const preview = trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
1749
+ throw new CaptureInputError(`native helper emitted a non-JSON line: ${preview}`);
1750
+ }
1751
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1752
+ throw new CaptureInputError("native helper chunk: expected a JSON object");
1753
+ }
1754
+ const obj = raw;
1755
+ if (typeof obj.path !== "string" || obj.path.trim() === "") {
1756
+ throw new CaptureInputError("native helper chunk.path: expected a non-empty string");
1757
+ }
1758
+ if (obj.channel !== "mic" && obj.channel !== "system") {
1759
+ throw new CaptureInputError('native helper chunk.channel: expected "mic" or "system"');
1760
+ }
1761
+ const startedAtUtc = parseTimestamp2(obj.startedAtUtc, "native helper chunk.startedAtUtc");
1762
+ const endedAtUtc = parseTimestamp2(obj.endedAtUtc, "native helper chunk.endedAtUtc");
1763
+ if (Date.parse(endedAtUtc) < Date.parse(startedAtUtc)) {
1764
+ throw new CaptureInputError("native helper chunk.endedAtUtc: must not precede startedAtUtc");
1765
+ }
1766
+ let device = null;
1767
+ if (obj.device !== void 0 && obj.device !== null) {
1768
+ if (typeof obj.device !== "string") {
1769
+ throw new CaptureInputError("native helper chunk.device: expected a string, null, or absent");
1770
+ }
1771
+ device = obj.device;
1772
+ }
1773
+ return { path: obj.path, channel: obj.channel, startedAtUtc, endedAtUtc, device };
1774
+ }
1775
+ function makeLineReader(onLine) {
1776
+ let buffer = "";
1777
+ return {
1778
+ push(chunk) {
1779
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
1780
+ let idx = buffer.indexOf("\n");
1781
+ while (idx >= 0) {
1782
+ onLine(buffer.slice(0, idx).replace(/\r$/, ""));
1783
+ buffer = buffer.slice(idx + 1);
1784
+ idx = buffer.indexOf("\n");
1785
+ }
1786
+ },
1787
+ flush() {
1788
+ if (buffer.length > 0) {
1789
+ const line = buffer.replace(/\r$/, "");
1790
+ buffer = "";
1791
+ onLine(line);
1792
+ }
1793
+ }
1794
+ };
1795
+ }
1796
+ var defaultSpawn = (binaryPath, args) => (
1797
+ // node's typings make stdout/stderr nullable; our piped stdio guarantees them.
1798
+ nodeSpawn(binaryPath, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] })
1799
+ );
1800
+ var MAX_ENUMERATE_BYTES = 1024 * 1024;
1801
+ var ENUMERATE_TIMEOUT_MS = 15e3;
1802
+ function enumerateDevices(binaryPath, spawn3 = defaultSpawn, timeoutMs = ENUMERATE_TIMEOUT_MS) {
1803
+ return new Promise((resolve, reject) => {
1804
+ const child = spawn3(binaryPath, ["device-enumerate"]);
1805
+ let out = "";
1806
+ let size = 0;
1807
+ let settled = false;
1808
+ const timer = setTimeout(() => {
1809
+ child.kill("SIGKILL");
1810
+ fail(new CaptureInputError("native helper device-enumerate timed out"));
1811
+ }, timeoutMs);
1812
+ timer.unref();
1813
+ const fail = (err) => {
1814
+ if (settled) return;
1815
+ settled = true;
1816
+ clearTimeout(timer);
1817
+ reject(err);
1818
+ };
1819
+ child.stdout.on("data", (chunk) => {
1820
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1821
+ size += Buffer.byteLength(text);
1822
+ if (size > MAX_ENUMERATE_BYTES) {
1823
+ child.kill("SIGKILL");
1824
+ fail(new CaptureInputError("native helper device-enumerate produced too much output"));
1825
+ return;
1826
+ }
1827
+ out += text;
1828
+ });
1829
+ child.stderr.on("data", () => void 0);
1830
+ child.once("error", (err) => fail(err instanceof Error ? err : new Error(String(err))));
1831
+ child.once("close", (code) => {
1832
+ if (settled) return;
1833
+ if (code !== 0) {
1834
+ fail(new CaptureInputError(`native helper device-enumerate exited with status ${code ?? "unknown"}`));
1835
+ return;
1836
+ }
1837
+ if (out.trim() === "") {
1838
+ fail(new CaptureInputError("native helper device-enumerate produced no output"));
1839
+ return;
1840
+ }
1841
+ let parsed;
1842
+ try {
1843
+ parsed = JSON.parse(out);
1844
+ } catch {
1845
+ fail(new CaptureInputError("native helper device-enumerate produced invalid JSON"));
1846
+ return;
1847
+ }
1848
+ let list = null;
1849
+ if (Array.isArray(parsed)) {
1850
+ list = parsed;
1851
+ } else if (parsed !== null && typeof parsed === "object" && "devices" in parsed) {
1852
+ const devices = parsed.devices;
1853
+ if (Array.isArray(devices)) list = devices;
1854
+ }
1855
+ if (list === null) {
1856
+ fail(new CaptureInputError("native helper device-enumerate did not return a device array"));
1857
+ return;
1858
+ }
1859
+ settled = true;
1860
+ clearTimeout(timer);
1861
+ resolve(list);
1862
+ });
1863
+ });
1864
+ }
1865
+ function createNativeCaptureRunner(options) {
1866
+ if (typeof options.outDir !== "string" || options.outDir.trim() === "") {
1867
+ throw new CaptureConfigError("native runner outDir must be a non-empty string");
1868
+ }
1869
+ if (!Number.isFinite(options.chunkSeconds) || options.chunkSeconds <= 0) {
1870
+ throw new CaptureConfigError("native runner chunkSeconds must be a positive number");
1871
+ }
1872
+ const spawn3 = options.spawn ?? defaultSpawn;
1873
+ const scheduleRestart = options.scheduleRestart ?? ((fn, delayMs) => setTimeout(fn, delayMs).unref());
1874
+ const cancelRestart = options.cancelRestart ?? ((timer) => clearTimeout(timer));
1875
+ const resolveBinary = options.resolveBinary ?? resolveHelperBinary;
1876
+ const maxRestarts = options.maxRestarts ?? 5;
1877
+ const baseBackoffMs = options.baseBackoffMs ?? 500;
1878
+ const maxBackoffMs = options.maxBackoffMs ?? 3e4;
1879
+ const args = buildHelperArgs(options);
1880
+ const onError = (error) => {
1881
+ options.onError?.(error);
1882
+ };
1883
+ let resolution = options.resolution;
1884
+ let stopped = true;
1885
+ let child;
1886
+ let restartTimer;
1887
+ let restarts = 0;
1888
+ let stopResolve = null;
1889
+ function scheduleUnexpectedRestart() {
1890
+ if (stopped) return;
1891
+ if (restarts >= maxRestarts) {
1892
+ stopped = true;
1893
+ onError(new Error(`native capture helper failed ${restarts} times; giving up`));
1894
+ return;
1895
+ }
1896
+ const delayMs = Math.min(maxBackoffMs, baseBackoffMs * 2 ** restarts);
1897
+ restarts += 1;
1898
+ restartTimer = scheduleRestart(() => {
1899
+ restartTimer = void 0;
1900
+ if (!stopped) spawnChild();
1901
+ }, delayMs);
1902
+ }
1903
+ function spawnChild() {
1904
+ if (resolution === void 0) {
1905
+ resolution = resolveBinary({});
1906
+ }
1907
+ const current = spawn3(resolution.binaryPath, args);
1908
+ child = current;
1909
+ let settled = false;
1910
+ const stdoutReader = makeLineReader((line) => {
1911
+ if (line.trim() === "") return;
1912
+ try {
1913
+ const event = parseChunkEvent(line);
1914
+ restarts = 0;
1915
+ options.onChunk(event);
1916
+ } catch (err) {
1917
+ onError(err instanceof Error ? err : new Error(String(err)));
1918
+ }
1919
+ });
1920
+ const stderrReader = makeLineReader((line) => {
1921
+ if (line.trim() !== "") options.onStderr?.(line);
1922
+ });
1923
+ current.stdout.on("data", (chunk) => stdoutReader.push(chunk));
1924
+ current.stderr.on("data", (chunk) => stderrReader.push(chunk));
1925
+ const settle = () => {
1926
+ settled = true;
1927
+ stdoutReader.flush();
1928
+ stderrReader.flush();
1929
+ if (child === current) child = void 0;
1930
+ if (stopResolve) {
1931
+ const done = stopResolve;
1932
+ stopResolve = null;
1933
+ done();
1934
+ }
1935
+ };
1936
+ current.once("error", (err) => {
1937
+ if (settled) return;
1938
+ settle();
1939
+ onError(err instanceof Error ? err : new Error(String(err)));
1940
+ scheduleUnexpectedRestart();
1941
+ });
1942
+ current.once("close", (code, signal) => {
1943
+ if (settled) return;
1944
+ settle();
1945
+ if (stopped) return;
1946
+ onError(
1947
+ new Error(`native capture helper exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`)
1948
+ );
1949
+ scheduleUnexpectedRestart();
1950
+ });
1951
+ }
1952
+ return {
1953
+ get running() {
1954
+ return !stopped;
1955
+ },
1956
+ start() {
1957
+ if (!stopped) return;
1958
+ stopped = false;
1959
+ restarts = 0;
1960
+ try {
1961
+ spawnChild();
1962
+ } catch (err) {
1963
+ stopped = true;
1964
+ throw err;
1965
+ }
1966
+ },
1967
+ stop() {
1968
+ if (stopped) return Promise.resolve();
1969
+ stopped = true;
1970
+ if (restartTimer !== void 0) {
1971
+ cancelRestart(restartTimer);
1972
+ restartTimer = void 0;
1973
+ }
1974
+ const current = child;
1975
+ if (!current || current.killed === true) {
1976
+ child = void 0;
1977
+ return Promise.resolve();
1978
+ }
1979
+ return new Promise((resolve) => {
1980
+ let done = false;
1981
+ const finish = () => {
1982
+ if (done) return;
1983
+ done = true;
1984
+ clearTimeout(killTimer);
1985
+ clearTimeout(hardTimer);
1986
+ resolve();
1987
+ };
1988
+ stopResolve = finish;
1989
+ const killTimer = setTimeout(() => {
1990
+ if (!done && current.killed !== true) current.kill("SIGKILL");
1991
+ }, STOP_DRAIN_TIMEOUT_MS);
1992
+ killTimer.unref();
1993
+ const hardTimer = setTimeout(finish, STOP_DRAIN_TIMEOUT_MS + 2e3);
1994
+ hardTimer.unref();
1995
+ current.kill("SIGTERM");
1996
+ });
1997
+ }
1998
+ };
1999
+ }
2000
+
2001
+ // src/dedup.ts
2002
+ var OVERLAP_TOLERANCE_MS = 5e3;
2003
+ var JACCARD_THRESHOLD = 0.8;
2004
+ function wordSet(text) {
2005
+ const words = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w.length > 0);
2006
+ return new Set(words);
2007
+ }
2008
+ function wordJaccard(a, b) {
2009
+ const setA = wordSet(a);
2010
+ const setB = wordSet(b);
2011
+ if (setA.size === 0 && setB.size === 0) return 1;
2012
+ if (setA.size === 0 || setB.size === 0) return 0;
2013
+ let intersection = 0;
2014
+ for (const w of setA) {
2015
+ if (setB.has(w)) intersection++;
2016
+ }
2017
+ const union = setA.size + setB.size - intersection;
2018
+ return union === 0 ? 0 : intersection / union;
2019
+ }
2020
+ function overlapsWithin(a, b, toleranceMs) {
2021
+ const aStart = Date.parse(a.startUtc);
2022
+ const aEnd = Date.parse(a.endUtc);
2023
+ const bStart = Date.parse(b.startUtc);
2024
+ const bEnd = Date.parse(b.endUtc);
2025
+ if (!Number.isFinite(aStart) || !Number.isFinite(aEnd) || !Number.isFinite(bStart) || !Number.isFinite(bEnd)) {
2026
+ return false;
2027
+ }
2028
+ return aStart <= bEnd + toleranceMs && bStart <= aEnd + toleranceMs;
2029
+ }
2030
+ function dedupeCrossChannel(segments, options = {}) {
2031
+ const toleranceMs = options.toleranceMs ?? OVERLAP_TOLERANCE_MS;
2032
+ const threshold = options.jaccardThreshold ?? JACCARD_THRESHOLD;
2033
+ const systemSegments = segments.filter((s) => s.channel === "system");
2034
+ return segments.filter((seg) => {
2035
+ if (seg.channel !== "mic") return true;
2036
+ const duplicated = systemSegments.some(
2037
+ (sys) => overlapsWithin(seg, sys, toleranceMs) && wordJaccard(seg.text, sys.text) >= threshold
2038
+ );
2039
+ return !duplicated;
2040
+ });
2041
+ }
2042
+
2043
+ // src/processor.ts
2044
+ import { createHash as createHash2 } from "crypto";
2045
+ function chunkStableId(event) {
2046
+ return `chk_${createHash2("sha1").update(event.path).digest("hex")}`;
2047
+ }
2048
+ function createChunkProcessor(deps) {
2049
+ let tail = Promise.resolve();
2050
+ let recovered = false;
2051
+ let openConversationId = null;
2052
+ const processedThisRun = /* @__PURE__ */ new Set();
2053
+ const dedupeConversation = (id) => {
2054
+ const segs = deps.spool.conversationSegmentsForDedup(id);
2055
+ if (segs.length === 0) return;
2056
+ const options = deps.dedupWindowMs !== void 0 ? { toleranceMs: deps.dedupWindowMs } : {};
2057
+ const keep = new Set(dedupeCrossChannel(segs, options).map((s) => s.id));
2058
+ const drop = segs.filter((s) => !keep.has(s.id)).map((s) => s.id);
2059
+ if (drop.length > 0) deps.spool.deleteSegments(drop);
2060
+ };
2061
+ const finalizeConv = (id) => {
2062
+ dedupeConversation(id);
2063
+ deps.spool.finalizeConversation(id);
2064
+ };
2065
+ const report = (error, event) => {
2066
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), event);
2067
+ };
2068
+ async function process2(event) {
2069
+ const chunkId = chunkStableId(event);
2070
+ if (processedThisRun.has(chunkId)) return;
2071
+ if (deps.spool.isChunkApplied(`${chunkId}:done`)) {
2072
+ processedThisRun.add(chunkId);
2073
+ try {
2074
+ await deps.cleanupRawAudio(event);
2075
+ } catch (err) {
2076
+ report(err, event);
2077
+ }
2078
+ return;
2079
+ }
2080
+ const isSpeech = deps.detectSpeech ? await deps.detectSpeech(event) : true;
2081
+ const raw = isSpeech ? await deps.transcribe({
2082
+ wavPath: event.path,
2083
+ modelPath: deps.resolveModel(),
2084
+ chunkStartedAtUtc: event.startedAtUtc
2085
+ }) : [];
2086
+ if (!recovered) {
2087
+ recovered = true;
2088
+ const prior = deps.spool.latestCapturingConversation();
2089
+ if (prior) {
2090
+ deps.assembler.resume(prior);
2091
+ openConversationId = prior.id;
2092
+ }
2093
+ }
2094
+ const closed = deps.assembler.closeIfIdle(event.startedAtUtc);
2095
+ if (closed !== null && closed === openConversationId) {
2096
+ finalizeConv(closed);
2097
+ openConversationId = null;
2098
+ }
2099
+ const selfVoiceEnrolled = deps.diarizer !== void 0 && deps.diarizer.clusters().some((c) => c.isSelf && c.embeddingCount > 0);
2100
+ const built = [];
2101
+ for (const s of raw) {
2102
+ const text = s.text.trim();
2103
+ if (text === "") continue;
2104
+ built.push({
2105
+ seg: { channel: event.channel, text, startUtc: s.startUtc, endUtc: s.endUtc, isWearer: event.channel === "mic" },
2106
+ raw: s
2107
+ });
2108
+ }
2109
+ if (built.length > 0) {
2110
+ const groups = [];
2111
+ for (const item of built) {
2112
+ const conv = deps.assembler.add(item.seg);
2113
+ const last = groups[groups.length - 1];
2114
+ if (last && last.id === conv.id) last.items.push(item);
2115
+ else groups.push({ id: conv.id, startedAtUtc: conv.startedAtUtc, items: [item] });
2116
+ }
2117
+ for (let g = 0; g < groups.length; g++) {
2118
+ const grp = groups[g];
2119
+ const key = groups.length === 1 ? chunkId : `${chunkId}:${g}`;
2120
+ if (deps.spool.isChunkApplied(key)) {
2121
+ openConversationId = grp.id;
2122
+ continue;
2123
+ }
2124
+ if (openConversationId !== null && openConversationId !== grp.id) {
2125
+ finalizeConv(openConversationId);
2126
+ }
2127
+ if (deps.embed && deps.diarizer) {
2128
+ for (const item of grp.items) {
2129
+ const embedding = await deps.embed(event, item.raw);
2130
+ const clusterId = deps.diarizer.assign(embedding);
2131
+ const assigned = deps.diarizer.clusters().find((c) => c.id === clusterId);
2132
+ let isWearer = assigned?.isSelf ?? false;
2133
+ if (!isWearer && event.channel === "mic" && !selfVoiceEnrolled) isWearer = true;
2134
+ item.seg.isWearer = isWearer;
2135
+ item.seg.speakerCluster = clusterId;
2136
+ }
2137
+ }
2138
+ if (deps.diarizer) {
2139
+ const touched = new Set(
2140
+ grp.items.map((it) => it.seg.speakerCluster).filter((id) => typeof id === "string")
2141
+ );
2142
+ if (touched.size > 0) {
2143
+ const byId = new Map(deps.diarizer.clusters().map((c) => [c.id, c]));
2144
+ for (const cid of touched) {
2145
+ const c = byId.get(cid);
2146
+ if (c) {
2147
+ deps.spool.upsertSpeaker({
2148
+ id: c.id,
2149
+ label: c.label,
2150
+ isSelf: c.isSelf,
2151
+ embeddingCount: c.embeddingCount,
2152
+ centroid: c.centroid,
2153
+ examples: c.examples
2154
+ });
2155
+ }
2156
+ }
2157
+ }
2158
+ }
2159
+ deps.spool.appendAssembledSegments({
2160
+ idempotencyKey: key,
2161
+ chunkId: key,
2162
+ conversationId: grp.id,
2163
+ startedAtUtc: grp.startedAtUtc,
2164
+ state: "capturing",
2165
+ device: event.device,
2166
+ wavPath: event.path,
2167
+ segments: grp.items.map((it) => it.seg)
2168
+ });
2169
+ openConversationId = grp.id;
2170
+ }
2171
+ }
2172
+ const chunkFullyProcessed = built.length > 0 || !deps.spool.isChunkApplied(`${chunkId}:0`);
2173
+ processedThisRun.add(chunkId);
2174
+ if (chunkFullyProcessed) {
2175
+ deps.spool.markChunkComplete(chunkId, openConversationId ?? "-");
2176
+ try {
2177
+ await deps.cleanupRawAudio(event);
2178
+ } catch (err) {
2179
+ report(err, event);
2180
+ }
2181
+ }
2182
+ }
2183
+ function enqueue(event) {
2184
+ tail = tail.then(() => process2(event)).catch((error) => report(error, event));
2185
+ }
2186
+ function drain() {
2187
+ return tail.then(() => void 0);
2188
+ }
2189
+ async function finalize() {
2190
+ await drain();
2191
+ deps.assembler.finalize();
2192
+ for (const id of deps.spool.capturingConversationIds()) dedupeConversation(id);
2193
+ return deps.spool.finalizeOpenConversations();
2194
+ }
2195
+ return { enqueue, drain, finalize };
2196
+ }
2197
+
2198
+ // src/stt.ts
2199
+ import { statSync as statSync2 } from "fs";
2200
+ import { spawn } from "child_process";
2201
+ function isRegularFile(filePath) {
2202
+ try {
2203
+ return statSync2(filePath).isFile();
2204
+ } catch {
2205
+ return false;
2206
+ }
2207
+ }
2208
+ function timestampAt(chunkStartedAtUtc, offsetMs) {
2209
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/.exec(chunkStartedAtUtc);
2210
+ if (!match) {
2211
+ throw new CaptureConfigError("chunk start timestamp is invalid");
2212
+ }
2213
+ const [, year, month, day, hour, minute, second, millisecond = "0"] = match;
2214
+ const startMs = Date.UTC(
2215
+ Number(year),
2216
+ Number(month) - 1,
2217
+ Number(day),
2218
+ Number(hour),
2219
+ Number(minute),
2220
+ Number(second),
2221
+ Number(millisecond.padEnd(3, "0"))
2222
+ );
2223
+ const start = new Date(startMs);
2224
+ if (start.getUTCFullYear() !== Number(year) || start.getUTCMonth() !== Number(month) - 1 || start.getUTCDate() !== Number(day) || start.getUTCHours() !== Number(hour) || start.getUTCMinutes() !== Number(minute) || start.getUTCSeconds() !== Number(second) || start.getUTCMilliseconds() !== Number(millisecond.padEnd(3, "0"))) {
2225
+ throw new CaptureConfigError("chunk start timestamp is invalid");
2226
+ }
2227
+ const timestampMs = startMs + offsetMs;
2228
+ if (!Number.isFinite(timestampMs) || Math.abs(timestampMs) > 864e13) {
2229
+ throw new CaptureConfigError("whisper-cli segment offset produces an invalid timestamp");
2230
+ }
2231
+ return new Date(timestampMs).toISOString();
2232
+ }
2233
+ function parseWhisperJson(output, chunkStartedAtUtc) {
2234
+ let parsed;
2235
+ try {
2236
+ parsed = JSON.parse(output);
2237
+ } catch {
2238
+ throw new CaptureConfigError("whisper-cli returned malformed JSON");
2239
+ }
2240
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.transcription)) {
2241
+ throw new CaptureConfigError("whisper-cli JSON must contain a transcription array");
2242
+ }
2243
+ return parsed.transcription.map((value, index) => {
2244
+ if (!value || typeof value !== "object") {
2245
+ throw new CaptureConfigError(`whisper-cli transcription[${index}] is invalid`);
2246
+ }
2247
+ const segment = value;
2248
+ if (typeof segment.text !== "string" || segment.text.trim() === "" || !segment.offsets || typeof segment.offsets.from !== "number" || typeof segment.offsets.to !== "number" || !Number.isFinite(segment.offsets.from) || !Number.isFinite(segment.offsets.to) || segment.offsets.from < 0 || segment.offsets.to < segment.offsets.from) {
2249
+ throw new CaptureConfigError(`whisper-cli transcription[${index}] is invalid`);
2250
+ }
2251
+ return {
2252
+ text: segment.text.trim(),
2253
+ startUtc: timestampAt(chunkStartedAtUtc, segment.offsets.from),
2254
+ endUtc: timestampAt(chunkStartedAtUtc, segment.offsets.to)
2255
+ };
2256
+ });
2257
+ }
2258
+ function resolveModelPath(configuredPath, defaultPath, exists = isRegularFile) {
2259
+ const modelPath = expandTilde(configuredPath?.trim() || defaultPath);
2260
+ if (!exists(modelPath)) {
2261
+ throw new CaptureConfigError(
2262
+ `whisper model not found at ${modelPath}; run 'remnic-capture-audio download-model --model base' or set stt.modelPath`
2263
+ );
2264
+ }
2265
+ return modelPath;
2266
+ }
2267
+ function buildWhisperArgs(wavPath, modelPath, threads) {
2268
+ const args = ["-m", modelPath, "-f", wavPath, "--no-prints", "--output-json", "--output-file", "-"];
2269
+ if (typeof threads === "number" && Number.isInteger(threads) && threads > 0) {
2270
+ args.push("-t", String(threads));
2271
+ }
2272
+ return args;
2273
+ }
2274
+ async function transcribeWithWhisper(input) {
2275
+ const result = await input.run("whisper-cli", buildWhisperArgs(input.wavPath, input.modelPath, input.threads));
2276
+ if (result.code !== 0) {
2277
+ throw new CaptureConfigError(`whisper-cli failed with exit code ${result.code}`);
2278
+ }
2279
+ return parseWhisperJson(result.stdout, input.chunkStartedAtUtc);
2280
+ }
2281
+ function runWhisperCli(command, args) {
2282
+ return new Promise((resolve, reject) => {
2283
+ const child = spawn(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
2284
+ const stdout = [];
2285
+ const stderr = [];
2286
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
2287
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
2288
+ child.once("error", (error) => {
2289
+ const code = error.code;
2290
+ if (code === "ENOENT") {
2291
+ reject(
2292
+ new CaptureConfigError(
2293
+ `whisper-cli executable '${command}' not found on PATH; install whisper.cpp or configure its path before transcribing`
2294
+ )
2295
+ );
2296
+ return;
2297
+ }
2298
+ reject(new CaptureConfigError(`failed to launch whisper-cli '${command}'${code ? ` (${code})` : ""}`));
2299
+ });
2300
+ child.once("close", (code) => {
2301
+ resolve({
2302
+ code: code ?? 1,
2303
+ stdout: Buffer.concat(stdout).toString("utf8"),
2304
+ stderr: Buffer.concat(stderr).toString("utf8")
2305
+ });
2306
+ });
2307
+ });
2308
+ }
2309
+
2310
+ // src/capture.ts
2311
+ import { realpathSync } from "fs";
2312
+ import { rm as rm3 } from "fs/promises";
2313
+ import path8 from "path";
2314
+ function createLiveCapture(options) {
2315
+ const { spool, config, outDir, defaultModelPath } = options;
2316
+ const resolveModel = options.resolveModel ?? (() => resolveModelPath(config.stt.modelPath ?? void 0, defaultModelPath));
2317
+ const transcribe = options.transcribe ?? ((input) => transcribeWithWhisper({
2318
+ wavPath: input.wavPath,
2319
+ modelPath: input.modelPath,
2320
+ chunkStartedAtUtc: input.chunkStartedAtUtc,
2321
+ threads: config.stt.threads,
2322
+ run: runWhisperCli
2323
+ }));
2324
+ const rawBase = path8.resolve(outDir);
2325
+ const realOrResolved = (p) => {
2326
+ try {
2327
+ return realpathSync(path8.resolve(p));
2328
+ } catch {
2329
+ return path8.resolve(p);
2330
+ }
2331
+ };
2332
+ const rawBaseReal = realOrResolved(rawBase);
2333
+ const withinRawDir = (p) => {
2334
+ const real = realOrResolved(p);
2335
+ return real === rawBaseReal || real.startsWith(rawBaseReal + path8.sep);
2336
+ };
2337
+ const cleanupRawAudio = options.cleanupRawAudio ?? (async (event) => {
2338
+ if (config.rawRetentionHours > 0) return;
2339
+ if (!withinRawDir(event.path)) return;
2340
+ await rm3(path8.resolve(event.path), { force: true });
2341
+ });
2342
+ const assembler = new ConversationAssembler({
2343
+ gapMinutes: config.conversationGapMinutes,
2344
+ ...options.makeConversationId ? { makeId: options.makeConversationId } : {}
2345
+ });
2346
+ const diarizer = options.embed ? new SpeakerClusterer(config.diarization.similarityThreshold, spool.readSpeakerClusters()) : void 0;
2347
+ const processor = createChunkProcessor({
2348
+ spool,
2349
+ assembler,
2350
+ resolveModel,
2351
+ transcribe,
2352
+ cleanupRawAudio,
2353
+ ...options.detectSpeech ? { detectSpeech: options.detectSpeech } : {},
2354
+ ...options.embed ? { embed: options.embed } : {},
2355
+ ...diarizer ? { diarizer } : {},
2356
+ ...options.onError ? { onError: (error) => options.onError?.(error) } : {}
2357
+ });
2358
+ const runner = createNativeCaptureRunner({
2359
+ outDir,
2360
+ chunkSeconds: config.chunkSeconds,
2361
+ // Channels to record (config.captureChannel, default "both"). With "both",
2362
+ // the processor's finalize-time cross-channel dedup drops mic segments that
2363
+ // duplicate system (loopback) speech, so it is stored once (the cleaner
2364
+ // system copy). Operators without system-audio permission can set "mic" so
2365
+ // microphone capture never depends on the system-audio path being available.
2366
+ channel: config.captureChannel,
2367
+ device: config.devices.mic,
2368
+ onChunk: (event) => {
2369
+ if (!withinRawDir(event.path)) {
2370
+ options.onError?.(new CaptureInputError(`native helper chunk path escapes the capture directory: ${event.path}`));
2371
+ return;
2372
+ }
2373
+ processor.enqueue({ ...event, path: path8.resolve(event.path) });
2374
+ },
2375
+ ...options.onError ? { onError: options.onError } : {},
2376
+ ...options.onStderr ? { onStderr: options.onStderr } : {},
2377
+ ...options.resolution ? { resolution: options.resolution } : {},
2378
+ ...options.resolveBinary ? { resolveBinary: options.resolveBinary } : {},
2379
+ ...options.spawn ? { spawn: options.spawn } : {},
2380
+ ...options.scheduleRestart ? { scheduleRestart: options.scheduleRestart } : {},
2381
+ ...options.cancelRestart ? { cancelRestart: options.cancelRestart } : {}
2382
+ });
2383
+ return {
2384
+ get running() {
2385
+ return runner.running;
2386
+ },
2387
+ processor,
2388
+ start() {
2389
+ resolveModel();
2390
+ runner.start();
2391
+ },
2392
+ async stop() {
2393
+ await runner.stop();
2394
+ return processor.finalize();
2395
+ }
2396
+ };
2397
+ }
2398
+
2399
+ // src/enroll.ts
2400
+ var SELF_SPEAKER_ID = "self";
2401
+ function enrollSelf(input) {
2402
+ const label = input.label ?? "You";
2403
+ if (input.embedding !== void 0) {
2404
+ if (!Array.isArray(input.embedding) || input.embedding.length === 0) {
2405
+ throw new CaptureConfigError("enroll-self embedding must be a non-empty number array");
2406
+ }
2407
+ for (const value of input.embedding) {
2408
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2409
+ throw new CaptureConfigError("enroll-self embedding must contain only finite numbers");
2410
+ }
2411
+ }
2412
+ const clusterer = new SpeakerClusterer(0.5);
2413
+ clusterer.enrollSelf(input.embedding);
2414
+ const self = clusterer.clusters().find((c) => c.isSelf);
2415
+ if (!self) {
2416
+ throw new CaptureConfigError("enroll-self failed to build the self speaker cluster");
2417
+ }
2418
+ input.spool.upsertSpeaker({
2419
+ id: self.id,
2420
+ isSelf: true,
2421
+ label,
2422
+ embeddingCount: self.embeddingCount,
2423
+ centroid: self.centroid,
2424
+ examples: self.examples
2425
+ });
2426
+ return { speakerId: self.id, label, hasEmbedding: true, dimensions: input.embedding.length };
2427
+ }
2428
+ input.spool.upsertSpeaker({ id: SELF_SPEAKER_ID, isSelf: true, label });
2429
+ const stored = input.spool.readSpeakerClusters().find((c) => c.id === SELF_SPEAKER_ID);
2430
+ const dimensions = stored?.centroid.length ?? 0;
2431
+ return { speakerId: SELF_SPEAKER_ID, label, hasEmbedding: dimensions > 0, dimensions };
2432
+ }
2433
+
2434
+ // src/service.ts
2435
+ import path9 from "path";
2436
+ var DEFAULT_SERVICE_LABEL = "com.remnic.capture-audio";
2437
+ var SYSTEMD_UNIT_NAME = "remnic-capture-audio.service";
2438
+ function xmlEscape(value) {
2439
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2440
+ }
2441
+ var SAFE_LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2442
+ function validateLabel(label) {
2443
+ if (!SAFE_LABEL.test(label)) {
2444
+ throw new CaptureConfigError(
2445
+ `install-service label must match ${SAFE_LABEL.source} (got: ${JSON.stringify(label)})`
2446
+ );
2447
+ }
2448
+ return label;
2449
+ }
2450
+ function renderLaunchAgent(spec) {
2451
+ const label = spec.label ?? DEFAULT_SERVICE_LABEL;
2452
+ const args = spec.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
2453
+ const envEntries = Object.entries(spec.environment ?? {}).map(([k, v]) => ` <key>${xmlEscape(k)}</key>
2454
+ <string>${xmlEscape(v)}</string>`).join("\n");
2455
+ const envBlock = envEntries === "" ? "" : ` <key>EnvironmentVariables</key>
2456
+ <dict>
2457
+ ${envEntries}
2458
+ </dict>
2459
+ `;
2460
+ return `<?xml version="1.0" encoding="UTF-8"?>
2461
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2462
+ <plist version="1.0">
2463
+ <dict>
2464
+ <key>Label</key>
2465
+ <string>${xmlEscape(label)}</string>
2466
+ <key>ProgramArguments</key>
2467
+ <array>
2468
+ ${args}
2469
+ </array>
2470
+ ${envBlock} <key>RunAtLoad</key>
2471
+ <true/>
2472
+ <key>KeepAlive</key>
2473
+ <true/>
2474
+ <key>ProcessType</key>
2475
+ <string>Background</string>
2476
+ <key>StandardOutPath</key>
2477
+ <string>${xmlEscape(spec.logPath)}</string>
2478
+ <key>StandardErrorPath</key>
2479
+ <string>${xmlEscape(spec.logPath)}</string>
2480
+ </dict>
2481
+ </plist>
2482
+ `;
2483
+ }
2484
+ function systemdArg(value) {
2485
+ const escaped = value.replace(/%/g, "%%");
2486
+ if (escaped === "" || /[\s"'\\]/.test(escaped)) {
2487
+ return `"${escaped.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2488
+ }
2489
+ return escaped;
2490
+ }
2491
+ function renderSystemdUnit(spec) {
2492
+ const execStart = spec.programArguments.map(systemdArg).join(" ");
2493
+ const logPath = spec.logPath.replace(/%/g, "%%");
2494
+ const envLines = Object.entries(spec.environment ?? {}).map(([k, v]) => `Environment=${systemdArg(`${k}=${v}`)}`).join("\n");
2495
+ const envBlock = envLines === "" ? "" : `${envLines}
2496
+ `;
2497
+ return `[Unit]
2498
+ Description=Remnic desktop audio capture daemon
2499
+ After=default.target
2500
+
2501
+ [Service]
2502
+ Type=simple
2503
+ ExecStart=${execStart}
2504
+ StandardOutput=append:${logPath}
2505
+ StandardError=append:${logPath}
2506
+ ${envBlock}Restart=on-failure
2507
+ RestartSec=5
2508
+
2509
+ [Install]
2510
+ WantedBy=default.target
2511
+ `;
2512
+ }
2513
+ function planService(deps) {
2514
+ const label = validateLabel(deps.spec.label ?? DEFAULT_SERVICE_LABEL);
2515
+ if (deps.platform === "darwin") {
2516
+ const target = path9.join(deps.home, "Library", "LaunchAgents", `${label}.plist`);
2517
+ return {
2518
+ platform: deps.platform,
2519
+ path: target,
2520
+ contents: renderLaunchAgent(deps.spec),
2521
+ loadHint: `launchctl load ${target}`
2522
+ };
2523
+ }
2524
+ if (deps.platform === "linux") {
2525
+ const unitName = deps.spec.label ? `${label}.service` : SYSTEMD_UNIT_NAME;
2526
+ const target = path9.join(deps.home, ".config", "systemd", "user", unitName);
2527
+ return {
2528
+ platform: deps.platform,
2529
+ path: target,
2530
+ contents: renderSystemdUnit(deps.spec),
2531
+ loadHint: `systemctl --user enable --now ${unitName}`
2532
+ };
2533
+ }
2534
+ throw new CaptureConfigError(`install-service is unsupported on platform "${deps.platform}"`);
2535
+ }
2536
+ function installService(deps) {
2537
+ const plan = planService(deps);
2538
+ if (!deps.force && deps.exists?.(plan.path)) {
2539
+ throw new CaptureConfigError(`a capture-audio service is already installed at ${plan.path} (use --force to replace)`);
2540
+ }
2541
+ deps.mkdir(path9.dirname(plan.path));
2542
+ deps.writeFile(plan.path, plan.contents);
2543
+ return plan;
2544
+ }
2545
+ function uninstallService(deps) {
2546
+ const plan = planService(deps);
2547
+ if (!deps.exists(plan.path)) return { plan, removed: false };
2548
+ deps.remove(plan.path);
2549
+ return { plan, removed: true };
2550
+ }
2551
+
2552
+ // src/cli.ts
2553
+ import { spawn as spawn2 } from "child_process";
2554
+ import { chmodSync as chmodSync3, existsSync as existsSync2, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync6, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2555
+ import path10 from "path";
2556
+ import { setTimeout as delay } from "timers/promises";
2557
+ import { homedir } from "os";
2558
+ var VALUE_FLAGS = {
2559
+ replay: true,
2560
+ host: true,
2561
+ port: true,
2562
+ listen: true,
2563
+ "base-dir": true,
2564
+ model: true,
2565
+ lines: true,
2566
+ label: true
2567
+ };
2568
+ var BOOLEAN_FLAGS = {
2569
+ foreground: true,
2570
+ force: true,
2571
+ help: true,
2572
+ capture: true,
2573
+ uninstall: true
2574
+ };
2575
+ var COMMAND_FLAGS = {
2576
+ init: { force: true },
2577
+ start: { foreground: true, replay: true, host: true, port: true, listen: true, capture: true },
2578
+ stop: { force: true },
2579
+ status: {},
2580
+ devices: {},
2581
+ logs: { lines: true },
2582
+ "download-model": { model: true },
2583
+ janitor: {},
2584
+ "install-service": { force: true, uninstall: true, label: true },
2585
+ "enroll-self": { label: true },
2586
+ help: {}
2587
+ };
2588
+ var GLOBAL_FLAGS = { "base-dir": true, help: true };
2589
+ var READINESS_TIMEOUT_MS = 1e4;
2590
+ var STOP_TIMEOUT_MS = 1e4;
2591
+ function parseArgs(argv) {
2592
+ const tokens = [];
2593
+ const flags = {};
2594
+ for (let i = 0; i < argv.length; i++) {
2595
+ const arg = argv[i];
2596
+ if (arg.startsWith("--")) {
2597
+ const key = arg.slice(2);
2598
+ if (Object.hasOwn(VALUE_FLAGS, key)) {
2599
+ const next = argv[i + 1];
2600
+ if (next === void 0 || next.startsWith("--")) {
2601
+ throw new CaptureInputError(`flag --${key} requires a value`);
2602
+ }
2603
+ flags[key] = next;
2604
+ i += 1;
2605
+ } else if (Object.hasOwn(BOOLEAN_FLAGS, key)) {
2606
+ flags[key] = true;
2607
+ } else {
2608
+ throw new CaptureInputError(`unknown flag --${key}`);
2609
+ }
2610
+ } else {
2611
+ tokens.push(arg);
2612
+ }
2613
+ }
2614
+ const command = tokens.length > 0 ? tokens[0] : "help";
2615
+ return { command, positionals: tokens.slice(1), flags };
2616
+ }
2617
+ function resolvePaths(flags, env) {
2618
+ const baseDir = typeof flags["base-dir"] === "string" ? captureBaseDir({ ...env, REMNIC_CAPTURE_DIR: flags["base-dir"] }) : captureBaseDir(env);
2619
+ return capturePaths(baseDir);
2620
+ }
2621
+ function loadConfigOrDefault(paths, stderr) {
2622
+ if (existsSync2(paths.configPath)) return loadDaemonConfig(paths.configPath);
2623
+ stderr(`no config at ${paths.configPath}; using defaults (run \`init\` to customize)`);
2624
+ return defaultDaemonConfig();
2625
+ }
2626
+ function applyBindingOverrides(config, flags) {
2627
+ const next = { ...config };
2628
+ if (typeof flags.listen === "string") {
2629
+ const idx = flags.listen.lastIndexOf(":");
2630
+ if (idx <= 0) throw new CaptureInputError(`--listen expects host:port, got '${flags.listen}'`);
2631
+ next.host = flags.listen.slice(0, idx);
2632
+ next.port = coerceNumber(flags.listen.slice(idx + 1), "--listen port", { integer: true, min: 1, max: 65535 });
2633
+ }
2634
+ if (typeof flags.host === "string") next.host = flags.host;
2635
+ if (typeof flags.port === "string") {
2636
+ next.port = coerceNumber(flags.port, "--port", { integer: true, min: 1, max: 65535 });
2637
+ }
2638
+ next.host = stripIpv6Brackets(next.host);
2639
+ return next;
2640
+ }
2641
+ function healthUrlFor(host, port) {
2642
+ return `http://${formatHostForUrl(host)}:${port}/v1/health`;
2643
+ }
2644
+ function recordHealthUrl(record, paths, stderr) {
2645
+ if (record.host !== null && record.port !== null) return healthUrlFor(record.host, record.port);
2646
+ const config = loadConfigOrDefault(paths, stderr);
2647
+ return healthUrlFor(config.host, config.port);
2648
+ }
2649
+ function tokenHeader(paths) {
2650
+ if (!existsSync2(paths.tokenPath)) return {};
2651
+ return { authorization: `Bearer ${readFileSync6(paths.tokenPath, "utf8").trim()}` };
2652
+ }
2653
+ function ensurePrivateDir(dir) {
2654
+ mkdirSync3(dir, { recursive: true, mode: 448 });
2655
+ try {
2656
+ chmodSync3(dir, 448);
2657
+ } catch {
2658
+ }
2659
+ }
2660
+ function describeError(err) {
2661
+ const code = err.code;
2662
+ if (typeof code === "string" && code) return code;
2663
+ return err instanceof Error ? err.name : "unknown error";
2664
+ }
2665
+ async function probeIdentity(paths, url) {
2666
+ try {
2667
+ const res = await fetch(url, { headers: tokenHeader(paths), signal: AbortSignal.timeout(2e3) });
2668
+ if (!res.ok) return null;
2669
+ const body = await res.json();
2670
+ if (typeof body.instanceId === "string" && typeof body.pid === "number") {
2671
+ return { instanceId: body.instanceId, pid: body.pid };
2672
+ }
2673
+ return null;
2674
+ } catch {
2675
+ return null;
2676
+ }
2677
+ }
2678
+ function recordChildPidOrTerminate(pid, paths, binding, stderr) {
2679
+ const existing = readPidRecord(paths.pidPath);
2680
+ if (existing !== null && existing.pid === pid && existing.instanceId !== null) {
2681
+ return true;
2682
+ }
2683
+ try {
2684
+ writePidFile(paths.pidPath, pid, binding);
2685
+ return true;
2686
+ } catch (err) {
2687
+ try {
2688
+ process.kill(pid, "SIGTERM");
2689
+ } catch {
2690
+ }
2691
+ stderr(`failed to record daemon pid: ${describeError(err)}; terminated child pid ${pid}`);
2692
+ return false;
2693
+ }
2694
+ }
2695
+ async function isOwnRunningDaemon(record, paths, stderr) {
2696
+ if (record.instanceId === null) return true;
2697
+ const live = await probeIdentity(paths, recordHealthUrl(record, paths, stderr));
2698
+ if (live === null) return true;
2699
+ return live.instanceId === record.instanceId && live.pid === record.pid;
2700
+ }
2701
+ async function recordedDaemonIsRunning(record, paths, stderr) {
2702
+ if (record.pid === process.pid) return false;
2703
+ if (!isProcessAlive(record.pid)) return false;
2704
+ return isOwnRunningDaemon(record, paths, stderr);
2705
+ }
2706
+ async function superviseReplay(spool, replayDir, io, signal) {
2707
+ await Promise.resolve();
2708
+ spool.setMeta("replay_status", "running");
2709
+ try {
2710
+ const summary = await ingestReplayDirResponsive(spool, replayDir, { signal });
2711
+ if (summary.aborted) {
2712
+ spool.setMeta("replay_status", "cancelled");
2713
+ io.stdout(`replay: cancelled after ${summary.conversationsIngested} conversation(s)`);
2714
+ } else {
2715
+ spool.setMeta("replay_status", "ok");
2716
+ io.stdout(
2717
+ `replay: ingested ${summary.conversationsIngested} conversation(s), ${summary.segmentsIngested} segment(s) from ${summary.files} fixture file(s)`
2718
+ );
2719
+ }
2720
+ } catch (err) {
2721
+ const message = err instanceof CaptureConfigError || err instanceof CaptureInputError ? err.message : describeError(err);
2722
+ const sanitized = message.replace(/\/\S+/g, "<path>");
2723
+ spool.setMeta("replay_status", `failed: ${sanitized}`);
2724
+ io.stderr(`replay ingestion failed: ${message}`);
2725
+ }
2726
+ }
2727
+ function cmdInit(paths, flags, stdout) {
2728
+ ensurePrivateDir(paths.baseDir);
2729
+ if (existsSync2(paths.configPath) && flags.force !== true) {
2730
+ stdout(`config already exists at ${paths.configPath} (use --force to overwrite)`);
2731
+ } else {
2732
+ writeFileSync3(paths.configPath, serializeDaemonConfig(defaultDaemonConfig()), "utf8");
2733
+ stdout(`wrote default config to ${paths.configPath}`);
2734
+ }
2735
+ const token = loadOrCreateToken(paths.tokenPath);
2736
+ stdout(`token ready at ${paths.tokenPath} (${token.length} chars, mode 0600)`);
2737
+ stdout(`spool will be created at ${paths.spoolPath} on first start`);
2738
+ return 0;
2739
+ }
2740
+ async function cmdDownloadModel(paths, flags, stdout, downloadModel) {
2741
+ if (typeof flags.model !== "string") throw new CaptureInputError("flag --model requires a value");
2742
+ const result = await downloadModel({ model: flags.model, directory: path10.join(paths.baseDir, "models") });
2743
+ stdout(`${result.downloaded ? "downloaded" : "model already present"} ${flags.model} to ${result.path}`);
2744
+ return 0;
2745
+ }
2746
+ async function cmdJanitor(paths, stdout, stderr) {
2747
+ const config = loadConfigOrDefault(paths, stderr);
2748
+ const removed = await pruneExpiredRawAudio(path10.join(paths.baseDir, "raw"), config.rawRetentionHours * 60 * 60 * 1e3);
2749
+ stdout(`janitor: removed ${removed.length} expired raw audio file(s)`);
2750
+ return 0;
2751
+ }
2752
+ async function cmdStart(paths, flags, env, stdout, stderr, spawnArgvPrefix) {
2753
+ const config = applyBindingOverrides(loadConfigOrDefault(paths, stderr), flags);
2754
+ if (!isLoopbackHost(config.host)) {
2755
+ stderr(
2756
+ `refusing to bind non-loopback host '${config.host}': capture-audio serves plain HTTP with no TLS contract; use a loopback address (127.0.0.1 or ::1)`
2757
+ );
2758
+ return 1;
2759
+ }
2760
+ const replayDir = typeof flags.replay === "string" ? expandTilde(flags.replay) : null;
2761
+ const previousRecord = readPidRecord(paths.pidPath);
2762
+ if (previousRecord !== null) {
2763
+ if (await recordedDaemonIsRunning(previousRecord, paths, stderr)) {
2764
+ stdout(`daemon already running (pid ${previousRecord.pid})`);
2765
+ return 0;
2766
+ }
2767
+ if (previousRecord.pid !== process.pid) {
2768
+ removePidFile(paths.pidPath);
2769
+ }
2770
+ }
2771
+ if (flags.foreground !== true) {
2772
+ const relaunch = spawnArgvPrefix.length > 0 ? [...spawnArgvPrefix] : [process.argv[1]];
2773
+ const forwarded = ["start", "--foreground"];
2774
+ if (replayDir) forwarded.push("--replay", replayDir);
2775
+ if (typeof flags["base-dir"] === "string") forwarded.push("--base-dir", flags["base-dir"]);
2776
+ if (typeof flags.host === "string") forwarded.push("--host", flags.host);
2777
+ if (typeof flags.port === "string") forwarded.push("--port", flags.port);
2778
+ if (typeof flags.listen === "string") forwarded.push("--listen", flags.listen);
2779
+ if (flags.capture === true) forwarded.push("--capture");
2780
+ ensurePrivateDir(paths.baseDir);
2781
+ const logFd = openSync(paths.logPath, "a");
2782
+ const child = spawn2(process.execPath, [...relaunch, ...forwarded], {
2783
+ detached: true,
2784
+ stdio: ["ignore", logFd, logFd],
2785
+ env: { ...process.env, ...env }
2786
+ });
2787
+ child.on("error", (err) => stderr(`daemon failed to launch: ${describeError(err)}`));
2788
+ child.unref();
2789
+ if (typeof child.pid !== "number") {
2790
+ stderr("failed to spawn daemon process");
2791
+ return 1;
2792
+ }
2793
+ if (!recordChildPidOrTerminate(child.pid, paths, { host: config.host, port: config.port }, stderr)) {
2794
+ return 1;
2795
+ }
2796
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
2797
+ while (Date.now() < deadline) {
2798
+ if (!isProcessAlive(child.pid)) {
2799
+ removePidFileIfOwner(paths.pidPath, child.pid);
2800
+ stderr(`daemon exited during startup; see ${paths.logPath}`);
2801
+ return 1;
2802
+ }
2803
+ if (readPidRecord(paths.pidPath)?.instanceId) {
2804
+ stdout(`started daemon (pid ${child.pid}); listening; logs at ${paths.logPath}`);
2805
+ return 0;
2806
+ }
2807
+ await delay(100);
2808
+ }
2809
+ try {
2810
+ process.kill(child.pid, "SIGTERM");
2811
+ } catch {
2812
+ }
2813
+ removePidFileIfOwner(paths.pidPath, child.pid);
2814
+ stderr(
2815
+ `daemon did not become ready within ${READINESS_TIMEOUT_MS / 1e3}s; terminated pid ${child.pid}. See ${paths.logPath}.`
2816
+ );
2817
+ return 1;
2818
+ }
2819
+ ensurePrivateDir(paths.baseDir);
2820
+ const token = loadOrCreateToken(paths.tokenPath);
2821
+ const spool = new Spool(paths.spoolPath);
2822
+ let live = null;
2823
+ if (flags.capture === true) {
2824
+ try {
2825
+ const rawDir = path10.join(paths.baseDir, "raw");
2826
+ mkdirSync3(rawDir, { recursive: true });
2827
+ live = createLiveCapture({
2828
+ spool,
2829
+ config,
2830
+ outDir: rawDir,
2831
+ defaultModelPath: path10.join(paths.baseDir, "models", "ggml-base.bin"),
2832
+ onError: (e) => stderr(`capture: ${describeError(e)}`),
2833
+ onStderr: (l) => stderr(`helper: ${l}`)
2834
+ });
2835
+ live.start();
2836
+ } catch (err) {
2837
+ const detail = err instanceof CaptureConfigError || err instanceof CaptureInputError ? err.message : describeError(err);
2838
+ stderr(`live capture unavailable: ${detail}; serving without capture`);
2839
+ live = null;
2840
+ }
2841
+ }
2842
+ let handle;
2843
+ try {
2844
+ handle = await startDaemon({ spool, config, token, capturing: () => live !== null && live.running });
2845
+ } catch (err) {
2846
+ if (live) await live.stop().catch(() => void 0);
2847
+ spool.close();
2848
+ throw err;
2849
+ }
2850
+ try {
2851
+ writePidFile(paths.pidPath, process.pid, {
2852
+ instanceId: spool.meta("instance_id"),
2853
+ host: handle.host,
2854
+ port: handle.port
2855
+ });
2856
+ } catch (err) {
2857
+ if (live) await live.stop().catch(() => void 0);
2858
+ await handle.close();
2859
+ spool.close();
2860
+ throw err;
2861
+ }
2862
+ stdout(`listening on ${handle.url}`);
2863
+ if (live) stdout("live capture started");
2864
+ const replayAbort = new AbortController();
2865
+ const replayTask = replayDir ? superviseReplay(spool, replayDir, { stdout, stderr }, replayAbort.signal) : Promise.resolve();
2866
+ return await new Promise((resolve) => {
2867
+ let closing = false;
2868
+ const shutdown = () => {
2869
+ if (closing) return;
2870
+ closing = true;
2871
+ replayAbort.abort();
2872
+ void replayTask.catch(() => void 0).then(() => live ? live.stop().then(() => void 0) : void 0).catch(() => void 0).then(() => {
2873
+ spool.finalizeOpenConversations();
2874
+ return handle.close().catch(() => void 0);
2875
+ }).finally(() => {
2876
+ spool.close();
2877
+ removePidFileIfOwner(paths.pidPath, process.pid);
2878
+ resolve(0);
2879
+ });
2880
+ };
2881
+ process.once("SIGINT", shutdown);
2882
+ process.once("SIGTERM", shutdown);
2883
+ });
2884
+ }
2885
+ async function cmdStop(paths, flags, stdout, stderr) {
2886
+ const record = readPidRecord(paths.pidPath);
2887
+ if (record === null || !isProcessAlive(record.pid)) {
2888
+ removePidFile(paths.pidPath);
2889
+ stdout("daemon not running");
2890
+ return 0;
2891
+ }
2892
+ if (record.instanceId !== null) {
2893
+ const live = await probeIdentity(paths, recordHealthUrl(record, paths, stderr));
2894
+ if (live !== null && (live.instanceId !== record.instanceId || live.pid !== record.pid)) {
2895
+ stderr(
2896
+ `recorded pid ${record.pid} does not match the daemon serving this endpoint (identity/pid mismatch); not signalling and preserving ${paths.pidPath}. Stop the serving daemon via its own controls, or remove the pid file after verifying it is stale.`
2897
+ );
2898
+ return 1;
2899
+ }
2900
+ if (live === null && flags.force !== true) {
2901
+ stderr(
2902
+ `cannot confirm daemon identity for pid ${record.pid} (health unreachable); not signalling. Re-run \`stop --force\` to stop it anyway, or remove ${paths.pidPath}.`
2903
+ );
2904
+ return 1;
2905
+ }
2906
+ } else if (flags.force !== true) {
2907
+ stderr(
2908
+ `cannot verify daemon identity for pid ${record.pid} (no recorded instance id); not signalling. Re-run \`stop --force\` to stop it anyway, or remove ${paths.pidPath}.`
2909
+ );
2910
+ return 1;
2911
+ }
2912
+ try {
2913
+ process.kill(record.pid, "SIGTERM");
2914
+ } catch (err) {
2915
+ const code = err.code;
2916
+ if (code === "ESRCH") {
2917
+ removePidFile(paths.pidPath);
2918
+ stdout("daemon not running");
2919
+ return 0;
2920
+ }
2921
+ if (code === "EPERM") {
2922
+ stderr(`daemon (pid ${record.pid}) is running but not controllable from this user`);
2923
+ return 1;
2924
+ }
2925
+ throw err;
2926
+ }
2927
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
2928
+ while (Date.now() < deadline) {
2929
+ if (!isProcessAlive(record.pid) || readPidRecord(paths.pidPath) === null) {
2930
+ stdout(`daemon (pid ${record.pid}) stopped`);
2931
+ return 0;
2932
+ }
2933
+ await delay(100);
2934
+ }
2935
+ stdout(
2936
+ `sent SIGTERM to daemon (pid ${record.pid}); still shutting down after ${STOP_TIMEOUT_MS / 1e3}s`
2937
+ );
2938
+ return 0;
2939
+ }
2940
+ async function cmdStatus(paths, stdout, stderr) {
2941
+ const record = readPidRecord(paths.pidPath);
2942
+ if (record === null || !isProcessAlive(record.pid)) {
2943
+ stdout("status: not running");
2944
+ return 0;
2945
+ }
2946
+ try {
2947
+ const res = await fetch(recordHealthUrl(record, paths, stderr), {
2948
+ headers: tokenHeader(paths),
2949
+ signal: AbortSignal.timeout(2e3)
2950
+ });
2951
+ const body = await res.text();
2952
+ stdout(`status: running (pid ${record.pid}) \u2014 HTTP ${res.status} ${body}`);
2953
+ } catch (err) {
2954
+ stdout(`status: process alive (pid ${record.pid}) but health check failed (${describeError(err)})`);
2955
+ }
2956
+ return 0;
2957
+ }
2958
+ async function cmdDevices(env, stdout) {
2959
+ const { binaryPath } = resolveHelperBinary({ env });
2960
+ const devices = await enumerateDevices(binaryPath);
2961
+ stdout(JSON.stringify({ devices }, null, 2));
2962
+ return 0;
2963
+ }
2964
+ function cmdInstallService(paths, flags, env, stdout, spawnArgvPrefix) {
2965
+ const platform = process.platform;
2966
+ const home = homedir();
2967
+ const label = typeof flags.label === "string" ? flags.label : void 0;
2968
+ const environment = {};
2969
+ if (typeof env.PATH === "string" && env.PATH !== "") environment.PATH = env.PATH;
2970
+ const helperBin = env.REMNIC_CAPTURE_HELPER_BIN;
2971
+ if (typeof helperBin === "string" && helperBin !== "") environment.REMNIC_CAPTURE_HELPER_BIN = helperBin;
2972
+ const spec = {
2973
+ programArguments: [
2974
+ process.execPath,
2975
+ ...spawnArgvPrefix.length > 0 ? [...spawnArgvPrefix] : [process.argv[1]],
2976
+ "start",
2977
+ "--foreground",
2978
+ "--capture",
2979
+ "--base-dir",
2980
+ paths.baseDir
2981
+ ],
2982
+ logPath: paths.logPath,
2983
+ ...label ? { label } : {},
2984
+ ...Object.keys(environment).length > 0 ? { environment } : {}
2985
+ };
2986
+ if (flags.uninstall === true) {
2987
+ const { plan: plan2, removed } = uninstallService({
2988
+ platform,
2989
+ home,
2990
+ spec,
2991
+ exists: existsSync2,
2992
+ remove: (f) => rmSync2(f, { force: true })
2993
+ });
2994
+ stdout(removed ? `removed ${plan2.path}` : `no capture-audio service installed at ${plan2.path}`);
2995
+ return 0;
2996
+ }
2997
+ ensurePrivateDir(paths.baseDir);
2998
+ const plan = installService({
2999
+ platform,
3000
+ home,
3001
+ spec,
3002
+ force: flags.force === true,
3003
+ exists: existsSync2,
3004
+ mkdir: (dir) => mkdirSync3(dir, { recursive: true }),
3005
+ writeFile: (file, contents) => writeFileSync3(file, contents, { mode: 420 })
3006
+ });
3007
+ stdout(`installed ${plan.platform} service at ${plan.path}`);
3008
+ stdout(`enable it with: ${plan.loadHint}`);
3009
+ return 0;
3010
+ }
3011
+ function cmdEnrollSelf(paths, flags, stdout) {
3012
+ ensurePrivateDir(paths.baseDir);
3013
+ const spool = new Spool(paths.spoolPath);
3014
+ try {
3015
+ const label = typeof flags.label === "string" ? flags.label : void 0;
3016
+ const result = enrollSelf({ spool, label });
3017
+ stdout(`enrolled self speaker '${result.speakerId}' (${result.label})`);
3018
+ if (!result.hasEmbedding) {
3019
+ stdout("no voice embedding stored yet; voice-based diarization refinement lands with the diarization slice");
3020
+ }
3021
+ return 0;
3022
+ } finally {
3023
+ spool.close();
3024
+ }
3025
+ }
3026
+ function cmdLogs(paths, flags, stdout) {
3027
+ if (!existsSync2(paths.logPath)) {
3028
+ stdout(`no log file at ${paths.logPath}`);
3029
+ return 0;
3030
+ }
3031
+ const lines = typeof flags.lines === "string" ? coerceNumber(flags.lines, "--lines", { integer: true, min: 1 }) : 200;
3032
+ const all = readFileSync6(paths.logPath, "utf8").split("\n");
3033
+ stdout(all.slice(Math.max(0, all.length - lines)).join("\n"));
3034
+ return 0;
3035
+ }
3036
+ function usage(stdout) {
3037
+ stdout(
3038
+ [
3039
+ `remnic-capture-audio v${CAPTURE_AUDIO_VERSION}`,
3040
+ "usage: remnic-capture-audio <command> [flags]",
3041
+ "commands: init | start | stop | status | devices | logs | download-model | janitor | install-service | enroll-self",
3042
+ "start flags: --foreground --capture --replay <dir> --host <h> --port <n> --listen <host:port> --base-dir <dir>",
3043
+ "download-model flags: --model <base|small|large-v3-turbo-q5_0> --base-dir <dir>",
3044
+ "install-service flags: --force --uninstall --label <id>; enroll-self flags: --label <name>",
3045
+ "janitor uses rawRetentionHours from audio.json to remove expired files under raw/"
3046
+ ].join("\n")
3047
+ );
3048
+ return 0;
3049
+ }
3050
+ async function runCapture(io) {
3051
+ const env = io.env ?? process.env;
3052
+ const stdout = io.stdout ?? ((line) => console.log(line));
3053
+ const stderr = io.stderr ?? ((line) => console.error(line));
3054
+ try {
3055
+ const parsed = parseArgs(io.argv);
3056
+ const paths = resolvePaths(parsed.flags, env);
3057
+ if (parsed.flags.help === true || parsed.positionals.includes("-h") || parsed.positionals.includes("--help")) {
3058
+ return usage(stdout);
3059
+ }
3060
+ if (parsed.positionals.length > 0) {
3061
+ stderr(`unexpected argument(s): ${parsed.positionals.join(" ")}`);
3062
+ usage(stderr);
3063
+ return 2;
3064
+ }
3065
+ const allowedFlags = COMMAND_FLAGS[parsed.command];
3066
+ if (allowedFlags !== void 0) {
3067
+ for (const key of Object.keys(parsed.flags)) {
3068
+ if (!Object.hasOwn(GLOBAL_FLAGS, key) && !Object.hasOwn(allowedFlags, key)) {
3069
+ stderr(`flag --${key} is not valid for command '${parsed.command}'`);
3070
+ usage(stderr);
3071
+ return 2;
3072
+ }
3073
+ }
3074
+ }
3075
+ switch (parsed.command) {
3076
+ case "init":
3077
+ return cmdInit(paths, parsed.flags, stdout);
3078
+ case "start":
3079
+ return await cmdStart(paths, parsed.flags, env, stdout, stderr, io.spawnArgvPrefix ?? [process.argv[1]]);
3080
+ case "stop":
3081
+ return await cmdStop(paths, parsed.flags, stdout, stderr);
3082
+ case "status":
3083
+ return await cmdStatus(paths, stdout, stderr);
3084
+ case "devices":
3085
+ return await cmdDevices(env, stdout);
3086
+ case "logs":
3087
+ return cmdLogs(paths, parsed.flags, stdout);
3088
+ case "download-model":
3089
+ return await cmdDownloadModel(paths, parsed.flags, stdout, io.downloadModel ?? downloadWhisperModel);
3090
+ case "janitor":
3091
+ return await cmdJanitor(paths, stdout, stderr);
3092
+ case "install-service":
3093
+ return cmdInstallService(paths, parsed.flags, env, stdout, io.spawnArgvPrefix ?? [process.argv[1]]);
3094
+ case "enroll-self":
3095
+ return cmdEnrollSelf(paths, parsed.flags, stdout);
3096
+ case "help":
3097
+ case "--help":
3098
+ case "-h":
3099
+ return usage(stdout);
3100
+ default:
3101
+ stderr(`unknown command '${parsed.command}'`);
3102
+ usage(stderr);
3103
+ return 2;
3104
+ }
3105
+ } catch (err) {
3106
+ if (err instanceof CaptureConfigError || err instanceof CaptureInputError) {
3107
+ stderr(`error: ${err.message}`);
3108
+ return err instanceof CaptureInputError ? 2 : 1;
3109
+ }
3110
+ stderr(`error: ${describeError(err)}`);
3111
+ return 1;
3112
+ }
3113
+ }
3114
+
3115
+ export {
3116
+ CAPTURE_AUDIO_VERSION,
3117
+ DEFAULT_HOST,
3118
+ DEFAULT_PORT,
3119
+ SPOOL_SCHEMA_VERSION,
3120
+ CaptureConfigError,
3121
+ CaptureInputError,
3122
+ isLoopbackHost,
3123
+ defaultDaemonConfig,
3124
+ parseDaemonConfig,
3125
+ loadDaemonConfig,
3126
+ serializeDaemonConfig,
3127
+ writePidFile,
3128
+ readPidRecord,
3129
+ readPidFile,
3130
+ isProcessAlive,
3131
+ removePidFile,
3132
+ removePidFileIfOwner,
3133
+ generateToken,
3134
+ loadOrCreateToken,
3135
+ tokensMatch,
3136
+ bearerFromHeader,
3137
+ parseTranscriptDate,
3138
+ assertValidTimezone,
3139
+ parseLimit,
3140
+ encodeCursor,
3141
+ decodeCursor,
3142
+ createRequestHandler,
3143
+ startDaemon,
3144
+ expandTilde,
3145
+ captureBaseDir,
3146
+ capturePaths,
3147
+ REPLAY_COMMIT_BATCH,
3148
+ ingestReplayDir,
3149
+ ingestReplayDirResponsive,
3150
+ whisperModelUrl,
3151
+ downloadWhisperModel,
3152
+ pruneExpiredRawAudio,
3153
+ Spool,
3154
+ assembleConversations,
3155
+ DEFAULT_CONVERSATION_GAP_MINUTES,
3156
+ ConversationAssembler,
3157
+ cosineSimilarity,
3158
+ SpeakerClusterer,
3159
+ HELPER_BIN_ENV,
3160
+ helperPackageSpecifier,
3161
+ resolveHelperBinary,
3162
+ buildHelperArgs,
3163
+ parseChunkEvent,
3164
+ enumerateDevices,
3165
+ createNativeCaptureRunner,
3166
+ wordJaccard,
3167
+ dedupeCrossChannel,
3168
+ chunkStableId,
3169
+ createChunkProcessor,
3170
+ parseWhisperJson,
3171
+ resolveModelPath,
3172
+ buildWhisperArgs,
3173
+ transcribeWithWhisper,
3174
+ runWhisperCli,
3175
+ createLiveCapture,
3176
+ SELF_SPEAKER_ID,
3177
+ enrollSelf,
3178
+ DEFAULT_SERVICE_LABEL,
3179
+ renderLaunchAgent,
3180
+ renderSystemdUnit,
3181
+ planService,
3182
+ installService,
3183
+ uninstallService,
3184
+ superviseReplay,
3185
+ runCapture
3186
+ };
3187
+ //# sourceMappingURL=chunk-BRVXKUZY.js.map