@remnic/capture-audio 9.54.2 → 9.54.3

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.
@@ -4,7 +4,7 @@
4
4
  var CAPTURE_AUDIO_VERSION = "9.14.0";
5
5
  var DEFAULT_HOST = "127.0.0.1";
6
6
  var DEFAULT_PORT = 4340;
7
- var SPOOL_SCHEMA_VERSION = 1;
7
+ var SPOOL_SCHEMA_VERSION = 2;
8
8
  var MAX_CONVERSATIONS_LIMIT = 500;
9
9
  var DEFAULT_CONVERSATIONS_LIMIT = 50;
10
10
 
@@ -128,6 +128,7 @@ function defaultDaemonConfig() {
128
128
  threshold: 0.5,
129
129
  threads: 1
130
130
  },
131
+ reorderWindowSeconds: 60,
131
132
  diarization: { similarityThreshold: 0.4 },
132
133
  stt: { engine: "whisper-cpp", modelPath: null, threads: null },
133
134
  denyApps: [],
@@ -140,6 +141,7 @@ var KNOWN_TOP_KEYS = {
140
141
  chunkSeconds: true,
141
142
  captureChannel: true,
142
143
  conversationGapMinutes: true,
144
+ reorderWindowSeconds: true,
143
145
  rawRetentionHours: true,
144
146
  spoolRetentionDays: true,
145
147
  vad: true,
@@ -195,6 +197,12 @@ function parseDaemonConfig(raw) {
195
197
  if (obj.spoolRetentionDays !== void 0) {
196
198
  cfg.spoolRetentionDays = coerceNumber(obj.spoolRetentionDays, "spoolRetentionDays", { integer: true, min: 1 });
197
199
  }
200
+ if (obj.reorderWindowSeconds !== void 0) {
201
+ cfg.reorderWindowSeconds = coerceNumber(obj.reorderWindowSeconds, "reorderWindowSeconds", {
202
+ min: 0,
203
+ max: 3600
204
+ });
205
+ }
198
206
  if (obj.vad !== void 0) {
199
207
  const vad = asObject(obj.vad, "vad");
200
208
  warnUnknownKeys(
@@ -947,7 +955,8 @@ CREATE TABLE IF NOT EXISTS segments (
947
955
  text TEXT NOT NULL,
948
956
  start_utc TEXT NOT NULL,
949
957
  end_utc TEXT NOT NULL,
950
- ordinal INTEGER NOT NULL DEFAULT 0
958
+ ordinal INTEGER NOT NULL DEFAULT 0,
959
+ embedding BLOB
951
960
  );
952
961
  CREATE TABLE IF NOT EXISTS speaker_clusters (
953
962
  id TEXT PRIMARY KEY,
@@ -965,6 +974,20 @@ CREATE TABLE IF NOT EXISTS applied_chunks (
965
974
  CREATE INDEX IF NOT EXISTS idx_conv_keyset ON conversations(started_at_utc, id);
966
975
  CREATE INDEX IF NOT EXISTS idx_seg_conv ON segments(conversation_id, ordinal);
967
976
  `;
977
+ function encodeEmbedding(embedding) {
978
+ if (!embedding || embedding.length === 0) return null;
979
+ return Buffer.from(JSON.stringify(embedding));
980
+ }
981
+ function decodeEmbedding(blob) {
982
+ if (!blob || blob.byteLength === 0) return null;
983
+ try {
984
+ const parsed = JSON.parse(Buffer.from(blob).toString("utf8"));
985
+ if (!Array.isArray(parsed)) return null;
986
+ return parsed.every((n) => typeof n === "number" && Number.isFinite(n)) ? parsed : null;
987
+ } catch {
988
+ return null;
989
+ }
990
+ }
968
991
  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
992
  function assertIsoInstant(value, label) {
970
993
  const match = typeof value === "string" ? ISO_INSTANT.exec(value) : null;
@@ -1008,7 +1031,25 @@ var Spool = class {
1008
1031
  } catch {
1009
1032
  }
1010
1033
  }
1011
- this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("schema_version", String(SPOOL_SCHEMA_VERSION));
1034
+ const storedVersion = this.#db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1035
+ if (storedVersion?.value !== void 0) {
1036
+ const parsed = Number(storedVersion.value);
1037
+ if (!Number.isInteger(parsed) || parsed < 1) {
1038
+ throw new CaptureConfigError(
1039
+ `spool schema_version is malformed: ${JSON.stringify(storedVersion.value)}`
1040
+ );
1041
+ }
1042
+ if (parsed > SPOOL_SCHEMA_VERSION) {
1043
+ throw new CaptureConfigError(
1044
+ `spool schema_version ${parsed} is newer than this build supports (${SPOOL_SCHEMA_VERSION})`
1045
+ );
1046
+ }
1047
+ }
1048
+ const segmentColumns = this.#db.prepare("PRAGMA table_info(segments)").all();
1049
+ if (!segmentColumns.some((column) => column.name === "embedding")) {
1050
+ this.#db.exec("ALTER TABLE segments ADD COLUMN embedding BLOB");
1051
+ }
1052
+ this.#db.prepare("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("schema_version", String(SPOOL_SCHEMA_VERSION));
1012
1053
  this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("instance_id", ulid());
1013
1054
  }
1014
1055
  close() {
@@ -1077,7 +1118,7 @@ var Spool = class {
1077
1118
  "INSERT INTO conversations(id, started_at_utc, ended_at_utc, state, segment_count) VALUES (?,?,?,?,?)"
1078
1119
  ).run(convId, startedAtUtc, endedAtUtc, state, segments.length);
1079
1120
  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 (?,?,?,?,?,?,?,?,?,?)"
1121
+ "INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal, embedding) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
1081
1122
  );
1082
1123
  for (let i = 0; i < segments.length; i++) {
1083
1124
  const seg = segments[i];
@@ -1091,7 +1132,8 @@ var Spool = class {
1091
1132
  seg.text,
1092
1133
  seg.startUtc,
1093
1134
  seg.endUtc,
1094
- i
1135
+ i,
1136
+ encodeEmbedding(seg.embedding)
1095
1137
  );
1096
1138
  }
1097
1139
  db.exec("COMMIT");
@@ -1166,7 +1208,7 @@ var Spool = class {
1166
1208
  const ordinalRow = db.prepare("SELECT COALESCE(MAX(ordinal), -1) + 1 AS n FROM segments WHERE conversation_id = ?").get(convId);
1167
1209
  const nextOrdinal = Number(ordinalRow.n);
1168
1210
  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 (?,?,?,?,?,?,?,?,?,?)"
1211
+ "INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal, embedding) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
1170
1212
  );
1171
1213
  for (let i = 0; i < segments.length; i++) {
1172
1214
  const seg = segments[i];
@@ -1180,7 +1222,8 @@ var Spool = class {
1180
1222
  seg.text,
1181
1223
  seg.startUtc,
1182
1224
  seg.endUtc,
1183
- nextOrdinal + i
1225
+ nextOrdinal + i,
1226
+ encodeEmbedding(seg.embedding)
1184
1227
  );
1185
1228
  }
1186
1229
  db.prepare(
@@ -1251,11 +1294,129 @@ var Spool = class {
1251
1294
  }
1252
1295
  return removed;
1253
1296
  }
1297
+ /**
1298
+ * Segments of one conversation that still need a speaker, chronological.
1299
+ *
1300
+ * Only rows with a stored embedding and no cluster yet: clustering runs at
1301
+ * finalize over the segments that SURVIVED dedup (issue #2145), and skipping
1302
+ * already-assigned rows keeps a repeated finalize from double-counting a
1303
+ * centroid.
1304
+ */
1305
+ conversationSegmentsForDiarization(conversationId) {
1306
+ const rows = this.#db.prepare(
1307
+ "SELECT id, channel, embedding FROM segments WHERE conversation_id = ? AND embedding IS NOT NULL AND speaker_cluster IS NULL ORDER BY start_utc ASC, ordinal ASC, id ASC"
1308
+ ).all(conversationId);
1309
+ const out = [];
1310
+ for (const row of rows) {
1311
+ const embedding = decodeEmbedding(row.embedding);
1312
+ if (embedding !== null) out.push({ id: row.id, channel: row.channel, embedding });
1313
+ }
1314
+ return out;
1315
+ }
1316
+ /**
1317
+ * Commit one conversation's diarization: cluster snapshots and the segment
1318
+ * assignments that produced them, in ONE transaction.
1319
+ *
1320
+ * Splitting the two lets a crash persist an updated `embedding_count` while
1321
+ * its segments stay unassigned; the next finalize would select the same rows
1322
+ * and count the same embeddings again (issue #2145). Atomicity is what makes
1323
+ * the repeated-finalize idempotency claim true.
1324
+ */
1325
+ commitDiarization(input) {
1326
+ if (input.assignments.length === 0 && input.clusters.length === 0) return 0;
1327
+ const stmt = this.#db.prepare("UPDATE segments SET speaker_cluster = ?, is_wearer = ? WHERE id = ?");
1328
+ let updated = 0;
1329
+ this.#db.exec("BEGIN");
1330
+ try {
1331
+ for (const cluster of input.clusters) this.#upsertSpeakerUnlocked(cluster);
1332
+ for (const assignment of input.assignments) {
1333
+ updated += Number(stmt.run(assignment.speakerCluster, assignment.isWearer ? 1 : 0, assignment.id).changes);
1334
+ }
1335
+ this.#db.exec("COMMIT");
1336
+ } catch (err) {
1337
+ this.#db.exec("ROLLBACK");
1338
+ throw err;
1339
+ }
1340
+ return updated;
1341
+ }
1254
1342
  /** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */
1255
1343
  capturingConversationIds() {
1256
1344
  const rows = this.#db.prepare("SELECT id FROM conversations WHERE state = 'capturing' ORDER BY id ASC").all();
1257
1345
  return rows.map((r) => r.id);
1258
1346
  }
1347
+ /**
1348
+ * Record a bare idempotency marker (no segments).
1349
+ *
1350
+ * Used to persist facts a later replay cannot re-derive — such as how many
1351
+ * segments a chunk's transcript produced, which is the only way to tell a
1352
+ * legitimately shorter retranscription from a missing tail (issue #2145).
1353
+ */
1354
+ markApplied(idempotencyKey, conversationId) {
1355
+ this.#db.prepare("INSERT OR IGNORE INTO applied_chunks(idempotency_key, conversation_id, applied_at_utc) VALUES (?,?,?)").run(idempotencyKey, conversationId, (/* @__PURE__ */ new Date()).toISOString());
1356
+ }
1357
+ /**
1358
+ * Whether ANY idempotency key for this chunk was applied.
1359
+ *
1360
+ * Only a SILENT replay needs this — a chunk partially applied by a binary
1361
+ * predating the transcript manifest has no manifest to compare, and a
1362
+ * zero-segment replay has no per-segment key to look up exactly. Speech
1363
+ * chunks use the indexed manifest lookup below, so continuous capture never
1364
+ * pays for this scan (issue #2145).
1365
+ */
1366
+ hasAppliedChunkPrefix(chunkIdPrefix) {
1367
+ const escaped = chunkIdPrefix.replace(/[\\%_]/g, "\\$&");
1368
+ const row = this.#db.prepare("SELECT 1 AS present FROM applied_chunks WHERE idempotency_key LIKE ? ESCAPE '\\' LIMIT 1").get(`${escaped}%`);
1369
+ return row?.present === 1;
1370
+ }
1371
+ /**
1372
+ * Conversations a chunk actually contributed stored segments to.
1373
+ *
1374
+ * Used to scope a rebuilt replay hold to the prefix that chunk belongs to,
1375
+ * rather than to every conversation that happens to be capturing (#2145).
1376
+ * Matches the bare chunk id and every per-segment or per-group derivative
1377
+ * (`<chunkId>:h<hash>`, and the pre-manifest `<chunkId>:<n>`).
1378
+ */
1379
+ conversationIdsForChunk(chunkId) {
1380
+ const escaped = chunkId.replace(/[\\%_]/g, "\\$&");
1381
+ const rows = this.#db.prepare(
1382
+ `SELECT DISTINCT conversation_id AS conversationId
1383
+ FROM segments
1384
+ WHERE conversation_id IS NOT NULL
1385
+ AND (chunk_id = ? OR chunk_id LIKE ? ESCAPE '\\')
1386
+ ORDER BY conversation_id`
1387
+ ).all(chunkId, `${escaped}:%`);
1388
+ return rows.map((row) => row.conversationId);
1389
+ }
1390
+ /**
1391
+ * Chunks whose transcript manifest is recorded but which never completed.
1392
+ *
1393
+ * A restart loses the in-memory record of which chunks are still awaiting a
1394
+ * replay, so it is re-derived from these two durable markers: the manifest is
1395
+ * written before any append, `:done` only after every segment is stored
1396
+ * (issue #2145).
1397
+ */
1398
+ incompleteChunkIds() {
1399
+ const rows = this.#db.prepare(
1400
+ `SELECT substr(idempotency_key, 1, length(idempotency_key) - 9) AS chunkId
1401
+ FROM applied_chunks
1402
+ WHERE idempotency_key LIKE '%:manifest'
1403
+ AND substr(idempotency_key, 1, length(idempotency_key) - 9) || ':done' NOT IN (
1404
+ SELECT idempotency_key FROM applied_chunks
1405
+ )`
1406
+ ).all();
1407
+ return rows.map((row) => row.chunkId);
1408
+ }
1409
+ /**
1410
+ * The value stored alongside an idempotency marker, or `undefined`.
1411
+ *
1412
+ * `markApplied` uses this column to carry a fact a replay cannot re-derive —
1413
+ * the chunk's transcript manifest hash — and this is the exact, primary-key
1414
+ * lookup that reads it back (issue #2145).
1415
+ */
1416
+ appliedChunkValue(idempotencyKey) {
1417
+ const row = this.#db.prepare("SELECT conversation_id AS conversationId FROM applied_chunks WHERE idempotency_key = ?").get(idempotencyKey);
1418
+ return row?.conversationId;
1419
+ }
1259
1420
  /** Whether a chunk with this idempotency key was already durably applied. */
1260
1421
  isChunkApplied(idempotencyKey) {
1261
1422
  return this.#db.prepare("SELECT 1 FROM applied_chunks WHERE idempotency_key = ? LIMIT 1").get(idempotencyKey) !== void 0;
@@ -1280,11 +1441,23 @@ var Spool = class {
1280
1441
  if (!row) return null;
1281
1442
  return { id: row.id, startedAtUtc: row.startedAtUtc, endedAtUtc: row.endedAtUtc ?? row.startedAtUtc };
1282
1443
  }
1444
+ /** One capturing conversation by id, for resuming a specific prefix. */
1445
+ capturingConversationById(id) {
1446
+ const row = this.#db.prepare(
1447
+ "SELECT id, started_at_utc AS startedAtUtc, ended_at_utc AS endedAtUtc FROM conversations WHERE id = ? AND state = 'capturing'"
1448
+ ).get(id);
1449
+ if (!row) return null;
1450
+ return { id: row.id, startedAtUtc: row.startedAtUtc, endedAtUtc: row.endedAtUtc ?? row.startedAtUtc };
1451
+ }
1283
1452
  #segmentCount(conversationId) {
1284
1453
  const row = this.#db.prepare("SELECT segment_count AS n FROM conversations WHERE id = ?").get(conversationId);
1285
1454
  return row ? Number(row.n) : 0;
1286
1455
  }
1287
1456
  upsertSpeaker(input) {
1457
+ this.#upsertSpeakerUnlocked(input);
1458
+ }
1459
+ /** `upsertSpeaker` without its own transaction, for use inside one. */
1460
+ #upsertSpeakerUnlocked(input) {
1288
1461
  const current = this.#db.prepare(
1289
1462
  "SELECT label, embedding_count AS embeddingCount, is_self AS isSelf, centroid, example_embeddings AS examples FROM speaker_clusters WHERE id = ?"
1290
1463
  ).get(input.id);
@@ -1421,6 +1594,12 @@ function assembleConversations(segments, gapMinutes, state = "final") {
1421
1594
  return conversations;
1422
1595
  }
1423
1596
  var DEFAULT_CONVERSATION_GAP_MINUTES = 10;
1597
+ function cloneSegment(segment) {
1598
+ return {
1599
+ ...segment,
1600
+ ...segment.embedding ? { embedding: segment.embedding.slice() } : {}
1601
+ };
1602
+ }
1424
1603
  function epochMs(value, field) {
1425
1604
  const ms = Date.parse(value);
1426
1605
  if (!Number.isFinite(ms)) {
@@ -1489,6 +1668,40 @@ var ConversationAssembler = class {
1489
1668
  segments: []
1490
1669
  });
1491
1670
  }
1671
+ /**
1672
+ * Drop finalized conversations the caller no longer needs.
1673
+ *
1674
+ * A long-running daemon would otherwise retain every conversation and every
1675
+ * segment forever, which makes the rollback snapshot below O(capture
1676
+ * history) and the daemon's per-chunk work quadratic (issue #2145). Only the
1677
+ * open conversation can still be mutated, so nothing else needs keeping.
1678
+ */
1679
+ pruneFinalized() {
1680
+ const open = this.#open();
1681
+ const removed = this.#conversations.length - (open ? 1 : 0);
1682
+ this.#conversations.length = 0;
1683
+ if (open) this.#conversations.push(open);
1684
+ return removed;
1685
+ }
1686
+ /**
1687
+ * Deep snapshot for rollback (issue #2145).
1688
+ *
1689
+ * `add` mutates the open conversation in place. A caller that fails BEFORE
1690
+ * anything was persisted must be able to rewind, or the retry feeds earlier
1691
+ * timestamps into an advanced assembler and collapses conversations the
1692
+ * first attempt had split. A caller that already persisted something must
1693
+ * NOT rewind: the durable ids would then diverge from the in-memory ones.
1694
+ */
1695
+ checkpoint() {
1696
+ return this.#conversations.map((conv) => ({ ...conv, segments: conv.segments.map(cloneSegment) }));
1697
+ }
1698
+ /** Rewind to a {@link checkpoint}. */
1699
+ rewind(snapshot) {
1700
+ this.#conversations.length = 0;
1701
+ for (const conv of snapshot) {
1702
+ this.#conversations.push({ ...conv, segments: conv.segments.map(cloneSegment) });
1703
+ }
1704
+ }
1492
1705
  /** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */
1493
1706
  conversations() {
1494
1707
  return this.#conversations.map((conv) => ({ ...conv, segments: conv.segments.slice() }));
@@ -1634,6 +1847,27 @@ var SpeakerClusterer = class {
1634
1847
  this.#clusters.push(cluster);
1635
1848
  return cluster.id;
1636
1849
  }
1850
+ /**
1851
+ * Replace every cluster with `snapshot` (issue #2145).
1852
+ *
1853
+ * `assign` mutates centroids and counts in place, so a diarization commit
1854
+ * that rolls back in SQLite must roll back here too — otherwise the retry
1855
+ * counts the same embeddings twice. Deep-copied, so the caller's snapshot
1856
+ * cannot alias internal state.
1857
+ */
1858
+ restore(snapshot) {
1859
+ this.#clusters = snapshot.map((cluster) => ({
1860
+ ...cluster,
1861
+ centroid: cluster.centroid.slice(),
1862
+ examples: cluster.examples.map((example) => example.slice())
1863
+ }));
1864
+ let highest = 0;
1865
+ for (const cluster of this.#clusters) {
1866
+ const parsed = /^spk_(\d+)$/.exec(cluster.id);
1867
+ if (parsed) highest = Math.max(highest, Number(parsed[1]));
1868
+ }
1869
+ this.#next = highest + 1;
1870
+ }
1637
1871
  /** Snapshot for persistence. */
1638
1872
  clusters() {
1639
1873
  return this.#clusters.map((c) => ({
@@ -2042,14 +2276,74 @@ function dedupeCrossChannel(segments, options = {}) {
2042
2276
 
2043
2277
  // src/processor.ts
2044
2278
  import { createHash as createHash2 } from "crypto";
2279
+ import { log } from "@remnic/core/logger";
2280
+ function segmentStableKey(chunkId, segment) {
2281
+ const digest = createHash2("sha1").update(`${segment.startUtc}\0${segment.endUtc}\0${segment.text}`).digest("hex").slice(0, 16);
2282
+ return `${chunkId}:h${digest}`;
2283
+ }
2284
+ function transcriptManifestHash(chunkId, segments) {
2285
+ return createHash2("sha1").update(segments.map((segment) => segmentStableKey(chunkId, segment)).join("\n")).digest("hex").slice(0, 16);
2286
+ }
2287
+ function transcriptManifestKey(chunkId) {
2288
+ return `${chunkId}:manifest`;
2289
+ }
2045
2290
  function chunkStableId(event) {
2046
2291
  return `chk_${createHash2("sha1").update(event.path).digest("hex")}`;
2047
2292
  }
2293
+ var MAX_BUFFERED_CHUNKS = 512;
2294
+ function instantMs(value, field) {
2295
+ const ms = Date.parse(value);
2296
+ if (Number.isNaN(ms)) {
2297
+ throw new CaptureInputError(`${field}: expected an ISO-8601 instant, got ${JSON.stringify(value)}`);
2298
+ }
2299
+ return ms;
2300
+ }
2301
+ function firstStartMs(event, raw) {
2302
+ let earliest = instantMs(event.startedAtUtc, "chunk.startedAtUtc");
2303
+ for (const segment of raw) {
2304
+ earliest = Math.min(earliest, instantMs(segment.startUtc, "segment.startUtc"));
2305
+ }
2306
+ return earliest;
2307
+ }
2308
+ function lastEndMs(event, raw) {
2309
+ let latest = instantMs(event.endedAtUtc, "chunk.endedAtUtc");
2310
+ for (const segment of raw) {
2311
+ latest = Math.max(latest, instantMs(segment.endUtc, "segment.endUtc"));
2312
+ }
2313
+ return latest;
2314
+ }
2048
2315
  function createChunkProcessor(deps) {
2049
2316
  let tail = Promise.resolve();
2050
2317
  let recovered = false;
2051
2318
  let openConversationId = null;
2319
+ const retainedChunks = /* @__PURE__ */ new Map();
2320
+ function isHeldForReplay(conversationId) {
2321
+ for (const held of retainedChunks.values()) {
2322
+ if (held.has(conversationId)) return true;
2323
+ }
2324
+ return false;
2325
+ }
2326
+ function trackRetention(chunkId, retained, resumable) {
2327
+ if (!retained) {
2328
+ retainedChunks.delete(chunkId);
2329
+ return;
2330
+ }
2331
+ retainedChunks.set(chunkId, heldFor(chunkId, resumable));
2332
+ }
2333
+ function heldFor(chunkId, resumable) {
2334
+ const own = deps.spool.conversationIdsForChunk(chunkId).filter((id) => resumable.has(id));
2335
+ return own.length > 0 ? new Set(own) : new Set(resumable);
2336
+ }
2337
+ function resumableConversations() {
2338
+ const ids = new Set(deps.spool.capturingConversationIds());
2339
+ if (openConversationId !== null) ids.add(openConversationId);
2340
+ return ids;
2341
+ }
2052
2342
  const processedThisRun = /* @__PURE__ */ new Set();
2343
+ const reorderWindowMs = Math.max(0, deps.reorderWindowMs ?? 0);
2344
+ const buffer = [];
2345
+ const bufferedIds = /* @__PURE__ */ new Set();
2346
+ let watermarkSourceMs = Number.NEGATIVE_INFINITY;
2053
2347
  const dedupeConversation = (id) => {
2054
2348
  const segs = deps.spool.conversationSegmentsForDedup(id);
2055
2349
  if (segs.length === 0) return;
@@ -2058,16 +2352,54 @@ function createChunkProcessor(deps) {
2058
2352
  const drop = segs.filter((s) => !keep.has(s.id)).map((s) => s.id);
2059
2353
  if (drop.length > 0) deps.spool.deleteSegments(drop);
2060
2354
  };
2355
+ const diarizeConversation = (id) => {
2356
+ const diarizer = deps.diarizer;
2357
+ if (!diarizer) return;
2358
+ const pending = deps.spool.conversationSegmentsForDiarization(id);
2359
+ if (pending.length === 0) return;
2360
+ const before = diarizer.clusters();
2361
+ const selfVoiceEnrolled = diarizer.clusters().some((c) => c.isSelf && c.embeddingCount > 0);
2362
+ const assignments = [];
2363
+ const touched = /* @__PURE__ */ new Set();
2364
+ for (const segment of pending) {
2365
+ const clusterId = diarizer.assign(segment.embedding);
2366
+ const assigned = diarizer.clusters().find((c) => c.id === clusterId);
2367
+ const isWearer = (assigned?.isSelf ?? false) || segment.channel === "mic" && !selfVoiceEnrolled;
2368
+ assignments.push({ id: segment.id, speakerCluster: clusterId, isWearer });
2369
+ touched.add(clusterId);
2370
+ }
2371
+ const byId = new Map(diarizer.clusters().map((c) => [c.id, c]));
2372
+ const clusters = [...touched].map((clusterId) => byId.get(clusterId)).filter((cluster) => cluster !== void 0).map((cluster) => ({
2373
+ id: cluster.id,
2374
+ label: cluster.label,
2375
+ isSelf: cluster.isSelf,
2376
+ embeddingCount: cluster.embeddingCount,
2377
+ centroid: cluster.centroid,
2378
+ examples: cluster.examples
2379
+ }));
2380
+ try {
2381
+ deps.spool.commitDiarization({ clusters, assignments });
2382
+ } catch (err) {
2383
+ diarizer.restore(before);
2384
+ throw err;
2385
+ }
2386
+ };
2061
2387
  const finalizeConv = (id) => {
2388
+ if (isHeldForReplay(id)) return false;
2062
2389
  dedupeConversation(id);
2390
+ diarizeConversation(id);
2063
2391
  deps.spool.finalizeConversation(id);
2392
+ return true;
2064
2393
  };
2065
2394
  const report = (error, event) => {
2066
- deps.onError?.(error instanceof Error ? error : new Error(String(error)), event);
2395
+ try {
2396
+ deps.onError?.(error instanceof Error ? error : new Error(String(error)), event);
2397
+ } catch {
2398
+ }
2067
2399
  };
2068
2400
  async function process2(event) {
2069
2401
  const chunkId = chunkStableId(event);
2070
- if (processedThisRun.has(chunkId)) return;
2402
+ if (processedThisRun.has(chunkId) || bufferedIds.has(chunkId)) return;
2071
2403
  if (deps.spool.isChunkApplied(`${chunkId}:done`)) {
2072
2404
  processedThisRun.add(chunkId);
2073
2405
  try {
@@ -2077,107 +2409,258 @@ function createChunkProcessor(deps) {
2077
2409
  }
2078
2410
  return;
2079
2411
  }
2412
+ if (deps.spool.isChunkApplied(chunkId) || deps.spool.isChunkApplied(`${chunkId}:0`)) {
2413
+ processedThisRun.add(chunkId);
2414
+ log.warn(
2415
+ `[capture-audio] chunk ${chunkId} was partially applied under the pre-#2145 key scheme; leaving it and its raw audio in place for replay`
2416
+ );
2417
+ return;
2418
+ }
2080
2419
  const isSpeech = deps.detectSpeech ? await deps.detectSpeech(event) : true;
2081
2420
  const raw = isSpeech ? await deps.transcribe({
2082
2421
  wavPath: event.path,
2083
2422
  modelPath: deps.resolveModel(),
2084
2423
  chunkStartedAtUtc: event.startedAtUtc
2085
2424
  }) : [];
2086
- if (!recovered) {
2087
- recovered = true;
2088
- const prior = deps.spool.latestCapturingConversation();
2089
- if (prior) {
2090
- deps.assembler.resume(prior);
2091
- openConversationId = prior.id;
2425
+ const built = buildSegments(event, raw);
2426
+ buffer.push({
2427
+ event,
2428
+ chunkId,
2429
+ built,
2430
+ startMs: firstStartMs(event, raw),
2431
+ endMs: lastEndMs(event, raw),
2432
+ manifestRecorded: false
2433
+ });
2434
+ bufferedIds.add(chunkId);
2435
+ watermarkSourceMs = Math.max(watermarkSourceMs, lastEndMs(event, raw));
2436
+ if (buffer.length > MAX_BUFFERED_CHUNKS) {
2437
+ const evicted = buffer.shift();
2438
+ if (evicted !== void 0) {
2439
+ bufferedIds.delete(evicted.chunkId);
2440
+ report(
2441
+ new Error(
2442
+ `reorder buffer is full (${MAX_BUFFERED_CHUNKS}); chunk ${evicted.chunkId} was dropped with its raw audio retained`
2443
+ ),
2444
+ evicted.event
2445
+ );
2092
2446
  }
2093
2447
  }
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);
2448
+ await releaseReady(false);
2449
+ }
2450
+ function buildSegments(event, raw) {
2100
2451
  const built = [];
2101
2452
  for (const s of raw) {
2102
2453
  const text = s.text.trim();
2103
2454
  if (text === "") continue;
2104
2455
  built.push({
2105
- seg: { channel: event.channel, text, startUtc: s.startUtc, endUtc: s.endUtc, isWearer: event.channel === "mic" },
2456
+ seg: {
2457
+ channel: event.channel,
2458
+ text,
2459
+ startUtc: s.startUtc,
2460
+ endUtc: s.endUtc,
2461
+ isWearer: event.channel === "mic"
2462
+ },
2106
2463
  raw: s
2107
2464
  });
2108
2465
  }
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
- }
2466
+ return built;
2467
+ }
2468
+ async function applyBatch(batch, progress) {
2469
+ if (!recovered) {
2470
+ recovered = true;
2471
+ const ownPrefix = batch.flatMap((entry) => deps.spool.conversationIdsForChunk(entry.chunkId)).map((id) => deps.spool.capturingConversationById(id)).find((conversation) => conversation !== null);
2472
+ const prior = ownPrefix ?? deps.spool.latestCapturingConversation();
2473
+ if (prior) {
2474
+ deps.assembler.resume(prior);
2475
+ openConversationId = prior.id;
2476
+ }
2477
+ const capturingAtStartup = new Set(deps.spool.capturingConversationIds());
2478
+ for (const chunkId of deps.spool.incompleteChunkIds()) {
2479
+ const held = heldFor(chunkId, capturingAtStartup);
2480
+ if (held.size > 0) retainedChunks.set(chunkId, held);
2481
+ }
2482
+ }
2483
+ const resumable = resumableConversations();
2484
+ for (const entry of batch) trackRetention(entry.chunkId, !isFullyProcessed(entry), resumable);
2485
+ const earliestStart = batch.reduce(
2486
+ (earliest, entry) => entry.startMs < earliest.startMs ? entry : earliest,
2487
+ batch[0]
2488
+ );
2489
+ const closed = deps.assembler.closeIfIdle(earliestStart.event.startedAtUtc);
2490
+ if (closed !== null && closed === openConversationId) {
2491
+ if (finalizeConv(closed)) {
2492
+ progress.persisted = true;
2493
+ openConversationId = null;
2494
+ }
2495
+ }
2496
+ const stream = batch.flatMap(
2497
+ (entry) => entry.built.map((item, index) => ({ entry, item, index }))
2498
+ ).sort((left, right) => {
2499
+ if (left.item.seg.startUtc !== right.item.seg.startUtc) {
2500
+ return left.item.seg.startUtc < right.item.seg.startUtc ? -1 : 1;
2501
+ }
2502
+ if (left.item.seg.endUtc !== right.item.seg.endUtc) {
2503
+ return left.item.seg.endUtc < right.item.seg.endUtc ? -1 : 1;
2504
+ }
2505
+ if (left.entry.chunkId !== right.entry.chunkId) {
2506
+ return left.entry.chunkId < right.entry.chunkId ? -1 : 1;
2507
+ }
2508
+ return left.index - right.index;
2509
+ });
2510
+ const fresh = stream.filter(
2511
+ ({ entry, item }) => !deps.spool.isChunkApplied(segmentStableKey(entry.chunkId, item.seg))
2512
+ );
2513
+ if (deps.embed) {
2514
+ for (const { entry, item } of fresh) {
2515
+ item.seg.embedding = await deps.embed(entry.event, item.raw);
2516
+ }
2517
+ }
2518
+ const runs = [];
2519
+ for (const { entry, item, index } of fresh) {
2520
+ const conv = deps.assembler.add(item.seg);
2521
+ const carried = { ...item, index };
2522
+ const last = runs[runs.length - 1];
2523
+ if (last && last.id === conv.id && last.entry === entry) last.items.push(carried);
2524
+ else runs.push({ entry, id: conv.id, startedAtUtc: conv.startedAtUtc, items: [carried] });
2525
+ }
2526
+ for (const run of runs) {
2527
+ const { chunkId, event } = run.entry;
2528
+ for (const item of run.items) {
2529
+ const key = segmentStableKey(chunkId, item.seg);
2530
+ if (openConversationId !== null && openConversationId !== run.id) {
2531
+ if (finalizeConv(openConversationId)) progress.persisted = true;
2158
2532
  }
2159
2533
  deps.spool.appendAssembledSegments({
2160
2534
  idempotencyKey: key,
2161
2535
  chunkId: key,
2162
- conversationId: grp.id,
2163
- startedAtUtc: grp.startedAtUtc,
2536
+ conversationId: run.id,
2537
+ startedAtUtc: run.startedAtUtc,
2164
2538
  state: "capturing",
2165
2539
  device: event.device,
2166
2540
  wavPath: event.path,
2167
- segments: grp.items.map((it) => it.seg)
2541
+ segments: [item.seg]
2168
2542
  });
2169
- openConversationId = grp.id;
2543
+ progress.persisted = true;
2544
+ openConversationId = run.id;
2170
2545
  }
2171
2546
  }
2172
- const chunkFullyProcessed = built.length > 0 || !deps.spool.isChunkApplied(`${chunkId}:0`);
2173
- processedThisRun.add(chunkId);
2174
- if (chunkFullyProcessed) {
2175
- deps.spool.markChunkComplete(chunkId, openConversationId ?? "-");
2547
+ for (const entry of batch) {
2548
+ const fullyProcessed = isFullyProcessed(entry);
2549
+ trackRetention(entry.chunkId, !fullyProcessed, resumable);
2550
+ if (entry.built.length > 0 && !fullyProcessed) {
2551
+ log.warn(
2552
+ `[capture-audio] chunk ${entry.chunkId} retranscribed to a different transcript than an earlier run; keeping its raw audio for replay`
2553
+ );
2554
+ }
2555
+ processedThisRun.add(entry.chunkId);
2556
+ if (!fullyProcessed) continue;
2557
+ deps.spool.markChunkComplete(entry.chunkId, openConversationId ?? "-");
2176
2558
  try {
2177
- await deps.cleanupRawAudio(event);
2559
+ await deps.cleanupRawAudio(entry.event);
2178
2560
  } catch (err) {
2179
- report(err, event);
2561
+ report(err, entry.event);
2562
+ }
2563
+ }
2564
+ }
2565
+ function isFullyProcessed(entry) {
2566
+ if (entry.built.length === 0) return !deps.spool.hasAppliedChunkPrefix(`${entry.chunkId}:`);
2567
+ return deps.spool.appliedChunkValue(transcriptManifestKey(entry.chunkId)) === transcriptManifestHash(entry.chunkId, entry.built.map((item) => item.seg));
2568
+ }
2569
+ function recordTranscriptManifest(entry) {
2570
+ if (entry.built.length === 0) {
2571
+ entry.manifestRecorded = true;
2572
+ return true;
2573
+ }
2574
+ try {
2575
+ const key = transcriptManifestKey(entry.chunkId);
2576
+ const ours = transcriptManifestHash(entry.chunkId, entry.built.map((item) => item.seg));
2577
+ const recorded = deps.spool.appliedChunkValue(key);
2578
+ if (recorded === void 0) {
2579
+ deps.spool.markApplied(key, ours);
2580
+ } else if (recorded !== ours) {
2581
+ return "conflict";
2582
+ }
2583
+ entry.manifestRecorded = true;
2584
+ return true;
2585
+ } catch (err) {
2586
+ report(err, entry.event);
2587
+ return false;
2588
+ }
2589
+ }
2590
+ async function releaseReady(flushAll) {
2591
+ let finalFailure;
2592
+ for (; ; ) {
2593
+ const threshold = flushAll ? Number.POSITIVE_INFINITY : watermarkSourceMs - reorderWindowMs;
2594
+ const batch = [];
2595
+ let manifestBlocked = false;
2596
+ for (let i = buffer.length - 1; i >= 0; i--) {
2597
+ const candidate = buffer[i];
2598
+ if (candidate.endMs > threshold) continue;
2599
+ const manifest = candidate.manifestRecorded ? true : recordTranscriptManifest(candidate);
2600
+ if (manifest === "conflict") {
2601
+ buffer.splice(i, 1);
2602
+ bufferedIds.delete(candidate.chunkId);
2603
+ trackRetention(candidate.chunkId, true, resumableConversations());
2604
+ report(
2605
+ new Error(
2606
+ `transcript for chunk ${candidate.chunkId} does not match the recorded manifest; raw audio retained`
2607
+ ),
2608
+ candidate.event
2609
+ );
2610
+ continue;
2611
+ }
2612
+ if (!manifest) {
2613
+ manifestBlocked = true;
2614
+ break;
2615
+ }
2616
+ buffer.splice(i, 1);
2617
+ bufferedIds.delete(candidate.chunkId);
2618
+ batch.push(candidate);
2619
+ }
2620
+ if (manifestBlocked) {
2621
+ for (const entry of batch) {
2622
+ buffer.push(entry);
2623
+ bufferedIds.add(entry.chunkId);
2624
+ }
2625
+ if (flushAll) {
2626
+ throw new Error("flush-plan manifest could not be persisted; chunks are retained for replay");
2627
+ }
2628
+ return;
2629
+ }
2630
+ if (batch.length === 0) {
2631
+ if (flushAll && buffer.length > 0) {
2632
+ throw new Error("buffered chunks could not be released; they are retained for replay");
2633
+ }
2634
+ return;
2635
+ }
2636
+ batch.sort(
2637
+ (left, right) => left.startMs - right.startMs || left.endMs - right.endMs || (left.chunkId < right.chunkId ? -1 : left.chunkId > right.chunkId ? 1 : 0)
2638
+ );
2639
+ const checkpoint = {
2640
+ assembler: deps.assembler.checkpoint(),
2641
+ recovered,
2642
+ openConversationId
2643
+ };
2644
+ const progress = { persisted: false };
2645
+ try {
2646
+ await applyBatch(batch, progress);
2647
+ } catch (error) {
2648
+ if (!progress.persisted) {
2649
+ deps.assembler.rewind(checkpoint.assembler);
2650
+ recovered = checkpoint.recovered;
2651
+ openConversationId = checkpoint.openConversationId;
2652
+ }
2653
+ for (const entry of batch) {
2654
+ if (processedThisRun.has(entry.chunkId) || bufferedIds.has(entry.chunkId)) continue;
2655
+ buffer.push(entry);
2656
+ bufferedIds.add(entry.chunkId);
2657
+ }
2658
+ if (flushAll) finalFailure ??= error;
2659
+ report(error, batch[0].event);
2660
+ if (finalFailure !== void 0) throw finalFailure;
2661
+ return;
2180
2662
  }
2663
+ deps.assembler.pruneFinalized();
2181
2664
  }
2182
2665
  }
2183
2666
  function enqueue(event) {
@@ -2187,10 +2670,31 @@ function createChunkProcessor(deps) {
2187
2670
  return tail.then(() => void 0);
2188
2671
  }
2189
2672
  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();
2673
+ const flush = tail.then(() => releaseReady(true));
2674
+ tail = flush.catch(() => void 0);
2675
+ let flushFailure;
2676
+ try {
2677
+ await flush;
2678
+ } catch (error) {
2679
+ flushFailure = error;
2680
+ }
2681
+ const holdOpen = flushFailure !== void 0;
2682
+ if (!holdOpen) deps.assembler.finalize();
2683
+ let sweepFailure;
2684
+ let closed = 0;
2685
+ for (const id of deps.spool.capturingConversationIds()) {
2686
+ try {
2687
+ dedupeConversation(id);
2688
+ if (!holdOpen && !isHeldForReplay(id)) diarizeConversation(id);
2689
+ if (!holdOpen && !isHeldForReplay(id) && deps.spool.finalizeConversation(id)) closed++;
2690
+ } catch (error) {
2691
+ sweepFailure ??= error;
2692
+ }
2693
+ }
2694
+ const total = sweepFailure === void 0 && !holdOpen && retainedChunks.size === 0 ? closed + deps.spool.finalizeOpenConversations() : closed;
2695
+ if (flushFailure !== void 0) throw flushFailure;
2696
+ if (sweepFailure !== void 0) throw sweepFailure;
2697
+ return total;
2194
2698
  }
2195
2699
  return { enqueue, drain, finalize };
2196
2700
  }
@@ -2350,6 +2854,13 @@ function createLiveCapture(options) {
2350
2854
  resolveModel,
2351
2855
  transcribe,
2352
2856
  cleanupRawAudio,
2857
+ // Cross-channel arrival skew: hold each transcribed chunk until the
2858
+ // watermark passes it, so a delayed system chunk still assembles into the
2859
+ // conversation it belongs to (issue #2145).
2860
+ // Only "both" has two independent chunk streams to interleave. A single
2861
+ // channel is already ordered, so buffering would add latency and widen the
2862
+ // crash window for nothing (issue #2145).
2863
+ reorderWindowMs: config.captureChannel === "both" ? config.reorderWindowSeconds * 1e3 : 0,
2353
2864
  ...options.detectSpeech ? { detectSpeech: options.detectSpeech } : {},
2354
2865
  ...options.embed ? { embed: options.embed } : {},
2355
2866
  ...diarizer ? { diarizer } : {},
@@ -2869,7 +3380,12 @@ async function cmdStart(paths, flags, env, stdout, stderr, spawnArgvPrefix) {
2869
3380
  if (closing) return;
2870
3381
  closing = true;
2871
3382
  replayAbort.abort();
2872
- void replayTask.catch(() => void 0).then(() => live ? live.stop().then(() => void 0) : void 0).catch(() => void 0).then(() => {
3383
+ void replayTask.catch(() => void 0).then(() => live ? live.stop().then(() => void 0) : void 0).catch((error) => {
3384
+ process.stderr.write(
3385
+ `[capture-audio] shutdown flush failed; retained raw audio was NOT ingested: ${describeError(error)}
3386
+ `
3387
+ );
3388
+ }).then(() => {
2873
3389
  spool.finalizeOpenConversations();
2874
3390
  return handle.close().catch(() => void 0);
2875
3391
  }).finally(() => {
@@ -3184,4 +3700,4 @@ export {
3184
3700
  superviseReplay,
3185
3701
  runCapture
3186
3702
  };
3187
- //# sourceMappingURL=chunk-BRVXKUZY.js.map
3703
+ //# sourceMappingURL=chunk-MFWH245M.js.map