@securecoms/agent-bridge 0.28.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -798,6 +798,23 @@ If you embed the bridge as a library (`new BridgeAgent(cfg, deps)`), semantic re
798
798
 
799
799
  ---
800
800
 
801
+ ## Release notes: 0.28.1
802
+
803
+ <!-- [COPY, NEW] this section. Gate: Eric sign-off before npm publish (release PR). -->
804
+
805
+ 0.28.1 makes semantic memory recall more reliable. Install it in place of
806
+ 0.28.0 and restart the bridge. `@securecoms/agent-runtime` 0.11.0 is unchanged.
807
+
808
+ - **After a restart.** When the model files are already on the machine, the
809
+ bridge loads the owner's private-chat memory at startup, so the first
810
+ messages after a restart get recall. Without the files, the model still
811
+ downloads on the owner's first private message, as before.
812
+ - **Time limit.** The bridge now waits up to 600 ms for a typed message's
813
+ memory search and up to 250 ms during a voice call (it was 150 ms for both).
814
+ - **Idle cache.** A memory cache in use is no longer dropped after 30 minutes.
815
+ - **`/healthz`.** New `recall_cache_bytes`: the bytes of this bridge's memory
816
+ caches, a number only.
817
+
801
818
  ## Release notes: 0.28.0
802
819
 
803
820
  0.28.0 adds semantic memory in the owner's private chat. Install it together
package/dist/bin.js CHANGED
@@ -30879,6 +30879,8 @@ function rankRecall(query, cache, o) {
30879
30879
 
30880
30880
  // src/memory/recall/recall-book.ts
30881
30881
  var DIMS = 384;
30882
+ var RECALL_QUERY_TIMEOUT_TEXT_MS = 600;
30883
+ var RECALL_QUERY_TIMEOUT_VOICE_MS = 250;
30882
30884
  var RecallBook = class _RecallBook {
30883
30885
  constructor(d) {
30884
30886
  this.d = d;
@@ -30932,7 +30934,6 @@ var RecallBook = class _RecallBook {
30932
30934
  static IDLE_MS = 30 * 6e4;
30933
30935
  static TOP_K = 5;
30934
30936
  static TEXT_MAX_CHARS = 2e3;
30935
- static QUERY_TIMEOUT_MS = 150;
30936
30937
  caches = /* @__PURE__ */ new Map();
30937
30938
  entries = /* @__PURE__ */ new Map();
30938
30939
  warming = /* @__PURE__ */ new Set();
@@ -30945,6 +30946,15 @@ var RecallBook = class _RecallBook {
30945
30946
  cacheWarm(conversationId) {
30946
30947
  return this.caches.has(conversationId);
30947
30948
  }
30949
+ /**
30950
+ * Task 27 Step 1: the bytes the warm vector caches hold (a number, content-free; /healthz `recall_cache_bytes`).
30951
+ * The backing buffer, not the view: a warm that skipped unreadable rows installs a shorter view of a full buffer.
30952
+ */
30953
+ cacheBytes() {
30954
+ let n = 0;
30955
+ for (const c of this.caches.values()) n += c.vecs.buffer.byteLength;
30956
+ return n;
30957
+ }
30948
30958
  /**
30949
30959
  * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
30950
30960
  * a read failure leaves the cache cold (fail closed: no recall).
@@ -31018,8 +31028,8 @@ var RecallBook = class _RecallBook {
31018
31028
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
31019
31029
  * replaces its purge and mode epochs with the ones captured when the turn
31020
31030
  * was received (Task 16 fix H-2), so a purge or a mode change after receipt
31021
- * refuses too. 150 ms bound on the query embed; a cold cache starts a warm
31022
- * and gives nothing.
31031
+ * refuses too. `timeoutMs` bounds the query embed (the caller passes its turn kind's bound); a cold cache
31032
+ * starts a warm and gives nothing.
31023
31033
  */
31024
31034
  async prepare(p) {
31025
31035
  const fresh = this.d.store.recallCapture(p.conversationId);
@@ -31036,6 +31046,7 @@ var RecallBook = class _RecallBook {
31036
31046
  void this.warm(p.conversationId);
31037
31047
  return;
31038
31048
  }
31049
+ cache.lastUse = now;
31039
31050
  const ac = new AbortController();
31040
31051
  let timer;
31041
31052
  const q = await Promise.race([
@@ -31044,12 +31055,11 @@ var RecallBook = class _RecallBook {
31044
31055
  timer = setTimeout(() => {
31045
31056
  ac.abort();
31046
31057
  r(null);
31047
- }, p.timeoutMs ?? _RecallBook.QUERY_TIMEOUT_MS);
31058
+ }, p.timeoutMs);
31048
31059
  })
31049
31060
  ]);
31050
31061
  clearTimeout(timer);
31051
31062
  if (!q || q.length !== DIMS) return;
31052
- cache.lastUse = this.nowMs();
31053
31063
  const ranked = rankRecall(
31054
31064
  q,
31055
31065
  { vecs: cache.vecs, dims: DIMS, messageIds: cache.messageIds, recallIds: cache.recallIds },
@@ -33147,6 +33157,13 @@ var BridgeAgent = class _BridgeAgent {
33147
33157
  recallStageEpochs = /* @__PURE__ */ new Map();
33148
33158
  /** 7b Task 16: in-flight fire-and-forget index taps; close() waits for them (bounded). */
33149
33159
  recallTapWork = /* @__PURE__ */ new Set();
33160
+ /**
33161
+ * §13.5 live check (2026-10-09, cold start): set when an apply turns recall on (the boot apply included), cleared
33162
+ * once the maintenance tick has started the DM prewarm, or when recall is off at a tick.
33163
+ */
33164
+ recallPrewarmPending = false;
33165
+ /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
33166
+ recallPrewarmWork = null;
33150
33167
  /**
33151
33168
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
33152
33169
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -35346,7 +35363,9 @@ var BridgeAgent = class _BridgeAgent {
35346
35363
  await this.notes.exclusive(async () => {
35347
35364
  if (this.store.applyRecallMode("semantic", mem.recallDays).purged > 0) this.onRecallRetentionChangedLocked();
35348
35365
  });
35366
+ const turnedOn = !this.recallConfig.on;
35349
35367
  this.recallConfig = { on: true };
35368
+ if (turnedOn) this.armRecallPrewarm();
35350
35369
  if (this.cfg.modelSource === "download" && !this.modelDownloadNoticeLogged) {
35351
35370
  this.modelDownloadNoticeLogged = true;
35352
35371
  writeServiceLog("info", "memory.model_download_enabled");
@@ -35367,6 +35386,54 @@ var BridgeAgent = class _BridgeAgent {
35367
35386
  writeServiceLog("warn", "memory.recall_mode_failed", {}, e);
35368
35387
  }
35369
35388
  }
35389
+ /**
35390
+ * §13.5 live check (2026-10-09, cold start). After a restart in semantic mode turn 1 got no recall (the embedder
35391
+ * was not ready: ensureStarted, return) and turn 2 none either (no cache: a background warm, return). So when an
35392
+ * apply turns recall on (the boot apply included) and the model files are on disk, the embedder is started here,
35393
+ * off the turn path; the
35394
+ * maintenance tick warms the DM caches once it is ready (runRecallPrewarm). Synchronous and never throws:
35395
+ * ensureStarted only schedules the start, so boot never waits for it.
35396
+ */
35397
+ armRecallPrewarm() {
35398
+ try {
35399
+ if (!this.recallProviderOn()) return;
35400
+ if (!this.embedder.filesPresent()) return;
35401
+ this.recallPrewarmPending = true;
35402
+ this.embedder.ensureStarted();
35403
+ } catch (e) {
35404
+ writeServiceLog("warn", "memory.recall_prewarm_failed", {}, e);
35405
+ }
35406
+ }
35407
+ /**
35408
+ * The maintenance tick's half of the cold-start prewarm: once the embedder is ready (no cache RAM is spent on an
35409
+ * embedder that may never come up), warm each DM conversation, one at a time, detached from the tick. DM is the
35410
+ * persisted is_dm === true, the rule the membership sweep uses (audit r1 M6); a vault channel is never one. The
35411
+ * prewarm only opens vectors through RecallBook.warm, which snapshots the capture first and installs nothing
35412
+ * across a clear, a mode change or a purge (H2, M-A3). It indexes nothing and prepares nothing. Recall turned off
35413
+ * before or during it stops it (I1, Q1). Never throws.
35414
+ */
35415
+ runRecallPrewarm() {
35416
+ if (!this.recallPrewarmPending || this.recallPrewarmWork || this.closingDown) return;
35417
+ if (this.embedder.state().state !== "ready") return;
35418
+ this.recallPrewarmPending = false;
35419
+ this.recallPrewarmWork = (async () => {
35420
+ try {
35421
+ const dms = this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c) && this.store.getChannelMeta(c)?.isDm === true);
35422
+ for (const c of dms) {
35423
+ if (this.closingDown || !this.recallProviderOn()) return;
35424
+ await this.recall.warm(c);
35425
+ }
35426
+ } catch (e) {
35427
+ writeServiceLog("warn", "memory.recall_prewarm_failed", {}, e);
35428
+ }
35429
+ })().finally(() => {
35430
+ this.recallPrewarmWork = null;
35431
+ });
35432
+ }
35433
+ /** @internal test seam: resolves once the DM prewarm in flight, if any, has settled. */
35434
+ async settleRecallPrewarm() {
35435
+ await this.recallPrewarmWork;
35436
+ }
35370
35437
  /** A retention change or a prune removed rows: drop the vector caches. Called only inside notes.exclusive. */
35371
35438
  onRecallRetentionChangedLocked() {
35372
35439
  this.recall.dropAllCachesLocked();
@@ -35376,8 +35443,8 @@ var BridgeAgent = class _BridgeAgent {
35376
35443
  this.recall.dropAllLocked();
35377
35444
  }
35378
35445
  /**
35379
- * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the 150 ms bound
35380
- * inside RecallBook.prepare. Called beside prepareNotesForTurn, before the
35446
+ * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the turn kind's
35447
+ * bound inside RecallBook.prepare (600 ms text, 250 ms voice; ruling 2026-10-09). Called beside prepareNotesForTurn, before the
35381
35448
  * turn is staged. The receipt epochs (Task 16 fix H-2) narrow the capture:
35382
35449
  * the in-cycle receipt, else the persisted staging marks; marks that exist
35383
35450
  * but lack an epoch give no recall (fail closed). Never throws.
@@ -35389,7 +35456,8 @@ var BridgeAgent = class _BridgeAgent {
35389
35456
  if (marks !== void 0 && (marks.purgeEpoch === void 0 || marks.modeEpoch === void 0)) return;
35390
35457
  const receipt = marks === void 0 ? void 0 : { purgeEpoch: marks.purgeEpoch, modeEpoch: marks.modeEpoch };
35391
35458
  const exclude = this.store.recentMemoryMessageIds(t.channelId, 50);
35392
- await this.recall.prepare({ messageId: t.messageId, conversationId: t.channelId, query: t.text, excludeIds: exclude, ...receipt ? { receipt } : {} });
35459
+ const timeoutMs = t.kind === "voice" ? RECALL_QUERY_TIMEOUT_VOICE_MS : RECALL_QUERY_TIMEOUT_TEXT_MS;
35460
+ await this.recall.prepare({ messageId: t.messageId, conversationId: t.channelId, query: t.text, excludeIds: exclude, timeoutMs, ...receipt ? { receipt } : {} });
35393
35461
  } catch (e) {
35394
35462
  writeServiceLog("warn", "memory.recall_prepare_failed", {}, e);
35395
35463
  }
@@ -35551,8 +35619,10 @@ var BridgeAgent = class _BridgeAgent {
35551
35619
  }
35552
35620
  if (!this.recallProviderOn()) {
35553
35621
  this.reembedHot = false;
35622
+ this.recallPrewarmPending = false;
35554
35623
  return;
35555
35624
  }
35625
+ this.runRecallPrewarm();
35556
35626
  if (!this.reembedHot && n % 60 !== 0) return;
35557
35627
  try {
35558
35628
  this.reembedHot = await this.reembed.tick() > 0;
@@ -37344,7 +37414,10 @@ var BridgeAgent = class _BridgeAgent {
37344
37414
  const turnText = parsed.text;
37345
37415
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
37346
37416
  const coalescible = this.voiceCoalesceWindowMs > 0 && m.voiceMode === true && speaker !== void 0 && parsed.transcribed === true && voiceGuidance !== void 0 && !isCallStart;
37347
- if (!coalescible) await this.prepareRecallForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization });
37417
+ if (!coalescible) {
37418
+ const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
37419
+ await this.prepareRecallForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
37420
+ }
37348
37421
  let fastPathRoomBlocked = false;
37349
37422
  if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
37350
37423
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
@@ -37428,7 +37501,7 @@ var BridgeAgent = class _BridgeAgent {
37428
37501
  const [firstId, ...tailIds] = win.ids;
37429
37502
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
37430
37503
  const text = win.parts.join(" ");
37431
- await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization });
37504
+ await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
37432
37505
  const merged = { ...held, text };
37433
37506
  void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
37434
37507
  let roomBlocked = false;
@@ -38174,6 +38247,7 @@ var BridgeAgent = class _BridgeAgent {
38174
38247
  retired_drained: this.provider.retiredDrainedCount(),
38175
38248
  // INV-CUSTODY-DURABLE §5.C.3: custody health.
38176
38249
  expired_drained: this.provider.expiredDrainedCount(),
38250
+ recall_cache_bytes: this.recall.cacheBytes(),
38177
38251
  custody_persist_failing: this.provider.custodyHealth().persistFailing,
38178
38252
  custody_persist_failures: this.provider.custodyHealth().persistFailures,
38179
38253
  custody_read_failing: this.provider.custodyHealth().readFailing,
@@ -38307,6 +38381,16 @@ var BridgeAgent = class _BridgeAgent {
38307
38381
  ]);
38308
38382
  if (timer) clearTimeout(timer);
38309
38383
  }
38384
+ if (this.recallPrewarmWork) {
38385
+ let timer;
38386
+ await Promise.race([
38387
+ this.recallPrewarmWork,
38388
+ new Promise((resolve3) => {
38389
+ timer = setTimeout(resolve3, _BridgeAgent.GRACEFUL_DRAIN_MS);
38390
+ })
38391
+ ]);
38392
+ if (timer) clearTimeout(timer);
38393
+ }
38310
38394
  this.embedder.close?.();
38311
38395
  if (this.replyWork.size) {
38312
38396
  let timer;
@@ -1192,6 +1192,13 @@ export declare class BridgeAgent {
1192
1192
  private readonly recallStageEpochs;
1193
1193
  /** 7b Task 16: in-flight fire-and-forget index taps; close() waits for them (bounded). */
1194
1194
  private readonly recallTapWork;
1195
+ /**
1196
+ * §13.5 live check (2026-10-09, cold start): set when an apply turns recall on (the boot apply included), cleared
1197
+ * once the maintenance tick has started the DM prewarm, or when recall is off at a tick.
1198
+ */
1199
+ private recallPrewarmPending;
1200
+ /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
1201
+ private recallPrewarmWork;
1195
1202
  /**
1196
1203
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
1197
1204
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -2001,13 +2008,33 @@ export declare class BridgeAgent {
2001
2008
  private applyPayloadMemory;
2002
2009
  /** Spec §5.3 (H3): mode state lives in the store; level-triggered after every refetch. Never throws. */
2003
2010
  private applyRecallModeFromPayload;
2011
+ /**
2012
+ * §13.5 live check (2026-10-09, cold start). After a restart in semantic mode turn 1 got no recall (the embedder
2013
+ * was not ready: ensureStarted, return) and turn 2 none either (no cache: a background warm, return). So when an
2014
+ * apply turns recall on (the boot apply included) and the model files are on disk, the embedder is started here,
2015
+ * off the turn path; the
2016
+ * maintenance tick warms the DM caches once it is ready (runRecallPrewarm). Synchronous and never throws:
2017
+ * ensureStarted only schedules the start, so boot never waits for it.
2018
+ */
2019
+ private armRecallPrewarm;
2020
+ /**
2021
+ * The maintenance tick's half of the cold-start prewarm: once the embedder is ready (no cache RAM is spent on an
2022
+ * embedder that may never come up), warm each DM conversation, one at a time, detached from the tick. DM is the
2023
+ * persisted is_dm === true, the rule the membership sweep uses (audit r1 M6); a vault channel is never one. The
2024
+ * prewarm only opens vectors through RecallBook.warm, which snapshots the capture first and installs nothing
2025
+ * across a clear, a mode change or a purge (H2, M-A3). It indexes nothing and prepares nothing. Recall turned off
2026
+ * before or during it stops it (I1, Q1). Never throws.
2027
+ */
2028
+ private runRecallPrewarm;
2029
+ /** @internal test seam: resolves once the DM prewarm in flight, if any, has settled. */
2030
+ settleRecallPrewarm(): Promise<void>;
2004
2031
  /** A retention change or a prune removed rows: drop the vector caches. Called only inside notes.exclusive. */
2005
2032
  private onRecallRetentionChangedLocked;
2006
2033
  /** Markdown mode deleted every row: drop the caches and the per-message map. Called only inside notes.exclusive. */
2007
2034
  private onRecallPurgedAllLocked;
2008
2035
  /**
2009
- * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the 150 ms bound
2010
- * inside RecallBook.prepare. Called beside prepareNotesForTurn, before the
2036
+ * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the turn kind's
2037
+ * bound inside RecallBook.prepare (600 ms text, 250 ms voice; ruling 2026-10-09). Called beside prepareNotesForTurn, before the
2011
2038
  * turn is staged. The receipt epochs (Task 16 fix H-2) narrow the capture:
2012
2039
  * the in-cycle receipt, else the persisted staging marks; marks that exist
2013
2040
  * but lack an epoch give no recall (fail closed). Never throws.
@@ -2877,6 +2904,9 @@ export declare class BridgeAgent {
2877
2904
  retired_drained: number;
2878
2905
  /** INV-CUSTODY-DURABLE §5.C.6: rows silently drained past expires_at. */
2879
2906
  expired_drained: number;
2907
+ /** 7b Task 27 Step 1: bytes held by this bridge's warm recall vector caches. A number, content-free; the fleet
2908
+ * RAM script (scripts/ops/semantic-memory-ram-budget.mjs) sums it with the shared embedder's RSS. */
2909
+ recall_cache_bytes: number;
2880
2910
  /** INV-CUSTODY-DURABLE §5.C.3: a decrypted, un-acked row is memory-only
2881
2911
  * (its durable write failed); a restart now would lose it. */
2882
2912
  custody_persist_failing: boolean;
package/dist/index.js CHANGED
@@ -30369,6 +30369,8 @@ function rankRecall(query, cache, o) {
30369
30369
 
30370
30370
  // src/memory/recall/recall-book.ts
30371
30371
  var DIMS = 384;
30372
+ var RECALL_QUERY_TIMEOUT_TEXT_MS = 600;
30373
+ var RECALL_QUERY_TIMEOUT_VOICE_MS = 250;
30372
30374
  var RecallBook = class _RecallBook {
30373
30375
  constructor(d) {
30374
30376
  this.d = d;
@@ -30422,7 +30424,6 @@ var RecallBook = class _RecallBook {
30422
30424
  static IDLE_MS = 30 * 6e4;
30423
30425
  static TOP_K = 5;
30424
30426
  static TEXT_MAX_CHARS = 2e3;
30425
- static QUERY_TIMEOUT_MS = 150;
30426
30427
  caches = /* @__PURE__ */ new Map();
30427
30428
  entries = /* @__PURE__ */ new Map();
30428
30429
  warming = /* @__PURE__ */ new Set();
@@ -30435,6 +30436,15 @@ var RecallBook = class _RecallBook {
30435
30436
  cacheWarm(conversationId) {
30436
30437
  return this.caches.has(conversationId);
30437
30438
  }
30439
+ /**
30440
+ * Task 27 Step 1: the bytes the warm vector caches hold (a number, content-free; /healthz `recall_cache_bytes`).
30441
+ * The backing buffer, not the view: a warm that skipped unreadable rows installs a shorter view of a full buffer.
30442
+ */
30443
+ cacheBytes() {
30444
+ let n = 0;
30445
+ for (const c of this.caches.values()) n += c.vecs.buffer.byteLength;
30446
+ return n;
30447
+ }
30438
30448
  /**
30439
30449
  * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
30440
30450
  * a read failure leaves the cache cold (fail closed: no recall).
@@ -30508,8 +30518,8 @@ var RecallBook = class _RecallBook {
30508
30518
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
30509
30519
  * replaces its purge and mode epochs with the ones captured when the turn
30510
30520
  * was received (Task 16 fix H-2), so a purge or a mode change after receipt
30511
- * refuses too. 150 ms bound on the query embed; a cold cache starts a warm
30512
- * and gives nothing.
30521
+ * refuses too. `timeoutMs` bounds the query embed (the caller passes its turn kind's bound); a cold cache
30522
+ * starts a warm and gives nothing.
30513
30523
  */
30514
30524
  async prepare(p) {
30515
30525
  const fresh = this.d.store.recallCapture(p.conversationId);
@@ -30526,6 +30536,7 @@ var RecallBook = class _RecallBook {
30526
30536
  void this.warm(p.conversationId);
30527
30537
  return;
30528
30538
  }
30539
+ cache.lastUse = now;
30529
30540
  const ac = new AbortController();
30530
30541
  let timer;
30531
30542
  const q = await Promise.race([
@@ -30534,12 +30545,11 @@ var RecallBook = class _RecallBook {
30534
30545
  timer = setTimeout(() => {
30535
30546
  ac.abort();
30536
30547
  r(null);
30537
- }, p.timeoutMs ?? _RecallBook.QUERY_TIMEOUT_MS);
30548
+ }, p.timeoutMs);
30538
30549
  })
30539
30550
  ]);
30540
30551
  clearTimeout(timer);
30541
30552
  if (!q || q.length !== DIMS) return;
30542
- cache.lastUse = this.nowMs();
30543
30553
  const ranked = rankRecall(
30544
30554
  q,
30545
30555
  { vecs: cache.vecs, dims: DIMS, messageIds: cache.messageIds, recallIds: cache.recallIds },
@@ -32897,6 +32907,13 @@ var BridgeAgent = class _BridgeAgent {
32897
32907
  recallStageEpochs = /* @__PURE__ */ new Map();
32898
32908
  /** 7b Task 16: in-flight fire-and-forget index taps; close() waits for them (bounded). */
32899
32909
  recallTapWork = /* @__PURE__ */ new Set();
32910
+ /**
32911
+ * §13.5 live check (2026-10-09, cold start): set when an apply turns recall on (the boot apply included), cleared
32912
+ * once the maintenance tick has started the DM prewarm, or when recall is off at a tick.
32913
+ */
32914
+ recallPrewarmPending = false;
32915
+ /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
32916
+ recallPrewarmWork = null;
32900
32917
  /**
32901
32918
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
32902
32919
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -35096,7 +35113,9 @@ var BridgeAgent = class _BridgeAgent {
35096
35113
  await this.notes.exclusive(async () => {
35097
35114
  if (this.store.applyRecallMode("semantic", mem.recallDays).purged > 0) this.onRecallRetentionChangedLocked();
35098
35115
  });
35116
+ const turnedOn = !this.recallConfig.on;
35099
35117
  this.recallConfig = { on: true };
35118
+ if (turnedOn) this.armRecallPrewarm();
35100
35119
  if (this.cfg.modelSource === "download" && !this.modelDownloadNoticeLogged) {
35101
35120
  this.modelDownloadNoticeLogged = true;
35102
35121
  writeServiceLog2("info", "memory.model_download_enabled");
@@ -35117,6 +35136,54 @@ var BridgeAgent = class _BridgeAgent {
35117
35136
  writeServiceLog2("warn", "memory.recall_mode_failed", {}, e);
35118
35137
  }
35119
35138
  }
35139
+ /**
35140
+ * §13.5 live check (2026-10-09, cold start). After a restart in semantic mode turn 1 got no recall (the embedder
35141
+ * was not ready: ensureStarted, return) and turn 2 none either (no cache: a background warm, return). So when an
35142
+ * apply turns recall on (the boot apply included) and the model files are on disk, the embedder is started here,
35143
+ * off the turn path; the
35144
+ * maintenance tick warms the DM caches once it is ready (runRecallPrewarm). Synchronous and never throws:
35145
+ * ensureStarted only schedules the start, so boot never waits for it.
35146
+ */
35147
+ armRecallPrewarm() {
35148
+ try {
35149
+ if (!this.recallProviderOn()) return;
35150
+ if (!this.embedder.filesPresent()) return;
35151
+ this.recallPrewarmPending = true;
35152
+ this.embedder.ensureStarted();
35153
+ } catch (e) {
35154
+ writeServiceLog2("warn", "memory.recall_prewarm_failed", {}, e);
35155
+ }
35156
+ }
35157
+ /**
35158
+ * The maintenance tick's half of the cold-start prewarm: once the embedder is ready (no cache RAM is spent on an
35159
+ * embedder that may never come up), warm each DM conversation, one at a time, detached from the tick. DM is the
35160
+ * persisted is_dm === true, the rule the membership sweep uses (audit r1 M6); a vault channel is never one. The
35161
+ * prewarm only opens vectors through RecallBook.warm, which snapshots the capture first and installs nothing
35162
+ * across a clear, a mode change or a purge (H2, M-A3). It indexes nothing and prepares nothing. Recall turned off
35163
+ * before or during it stops it (I1, Q1). Never throws.
35164
+ */
35165
+ runRecallPrewarm() {
35166
+ if (!this.recallPrewarmPending || this.recallPrewarmWork || this.closingDown) return;
35167
+ if (this.embedder.state().state !== "ready") return;
35168
+ this.recallPrewarmPending = false;
35169
+ this.recallPrewarmWork = (async () => {
35170
+ try {
35171
+ const dms = this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c) && this.store.getChannelMeta(c)?.isDm === true);
35172
+ for (const c of dms) {
35173
+ if (this.closingDown || !this.recallProviderOn()) return;
35174
+ await this.recall.warm(c);
35175
+ }
35176
+ } catch (e) {
35177
+ writeServiceLog2("warn", "memory.recall_prewarm_failed", {}, e);
35178
+ }
35179
+ })().finally(() => {
35180
+ this.recallPrewarmWork = null;
35181
+ });
35182
+ }
35183
+ /** @internal test seam: resolves once the DM prewarm in flight, if any, has settled. */
35184
+ async settleRecallPrewarm() {
35185
+ await this.recallPrewarmWork;
35186
+ }
35120
35187
  /** A retention change or a prune removed rows: drop the vector caches. Called only inside notes.exclusive. */
35121
35188
  onRecallRetentionChangedLocked() {
35122
35189
  this.recall.dropAllCachesLocked();
@@ -35126,8 +35193,8 @@ var BridgeAgent = class _BridgeAgent {
35126
35193
  this.recall.dropAllLocked();
35127
35194
  }
35128
35195
  /**
35129
- * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the 150 ms bound
35130
- * inside RecallBook.prepare. Called beside prepareNotesForTurn, before the
35196
+ * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the turn kind's
35197
+ * bound inside RecallBook.prepare (600 ms text, 250 ms voice; ruling 2026-10-09). Called beside prepareNotesForTurn, before the
35131
35198
  * turn is staged. The receipt epochs (Task 16 fix H-2) narrow the capture:
35132
35199
  * the in-cycle receipt, else the persisted staging marks; marks that exist
35133
35200
  * but lack an epoch give no recall (fail closed). Never throws.
@@ -35139,7 +35206,8 @@ var BridgeAgent = class _BridgeAgent {
35139
35206
  if (marks !== void 0 && (marks.purgeEpoch === void 0 || marks.modeEpoch === void 0)) return;
35140
35207
  const receipt = marks === void 0 ? void 0 : { purgeEpoch: marks.purgeEpoch, modeEpoch: marks.modeEpoch };
35141
35208
  const exclude = this.store.recentMemoryMessageIds(t.channelId, 50);
35142
- await this.recall.prepare({ messageId: t.messageId, conversationId: t.channelId, query: t.text, excludeIds: exclude, ...receipt ? { receipt } : {} });
35209
+ const timeoutMs = t.kind === "voice" ? RECALL_QUERY_TIMEOUT_VOICE_MS : RECALL_QUERY_TIMEOUT_TEXT_MS;
35210
+ await this.recall.prepare({ messageId: t.messageId, conversationId: t.channelId, query: t.text, excludeIds: exclude, timeoutMs, ...receipt ? { receipt } : {} });
35143
35211
  } catch (e) {
35144
35212
  writeServiceLog2("warn", "memory.recall_prepare_failed", {}, e);
35145
35213
  }
@@ -35301,8 +35369,10 @@ var BridgeAgent = class _BridgeAgent {
35301
35369
  }
35302
35370
  if (!this.recallProviderOn()) {
35303
35371
  this.reembedHot = false;
35372
+ this.recallPrewarmPending = false;
35304
35373
  return;
35305
35374
  }
35375
+ this.runRecallPrewarm();
35306
35376
  if (!this.reembedHot && n % 60 !== 0) return;
35307
35377
  try {
35308
35378
  this.reembedHot = await this.reembed.tick() > 0;
@@ -37094,7 +37164,10 @@ var BridgeAgent = class _BridgeAgent {
37094
37164
  const turnText = parsed.text;
37095
37165
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
37096
37166
  const coalescible = this.voiceCoalesceWindowMs > 0 && m.voiceMode === true && speaker !== void 0 && parsed.transcribed === true && voiceGuidance !== void 0 && !isCallStart;
37097
- if (!coalescible) await this.prepareRecallForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization });
37167
+ if (!coalescible) {
37168
+ const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
37169
+ await this.prepareRecallForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
37170
+ }
37098
37171
  let fastPathRoomBlocked = false;
37099
37172
  if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
37100
37173
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
@@ -37178,7 +37251,7 @@ var BridgeAgent = class _BridgeAgent {
37178
37251
  const [firstId, ...tailIds] = win.ids;
37179
37252
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
37180
37253
  const text = win.parts.join(" ");
37181
- await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization });
37254
+ await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
37182
37255
  const merged = { ...held, text };
37183
37256
  void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
37184
37257
  let roomBlocked = false;
@@ -37924,6 +37997,7 @@ var BridgeAgent = class _BridgeAgent {
37924
37997
  retired_drained: this.provider.retiredDrainedCount(),
37925
37998
  // INV-CUSTODY-DURABLE §5.C.3: custody health.
37926
37999
  expired_drained: this.provider.expiredDrainedCount(),
38000
+ recall_cache_bytes: this.recall.cacheBytes(),
37927
38001
  custody_persist_failing: this.provider.custodyHealth().persistFailing,
37928
38002
  custody_persist_failures: this.provider.custodyHealth().persistFailures,
37929
38003
  custody_read_failing: this.provider.custodyHealth().readFailing,
@@ -38057,6 +38131,16 @@ var BridgeAgent = class _BridgeAgent {
38057
38131
  ]);
38058
38132
  if (timer) clearTimeout(timer);
38059
38133
  }
38134
+ if (this.recallPrewarmWork) {
38135
+ let timer;
38136
+ await Promise.race([
38137
+ this.recallPrewarmWork,
38138
+ new Promise((resolve) => {
38139
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
38140
+ })
38141
+ ]);
38142
+ if (timer) clearTimeout(timer);
38143
+ }
38060
38144
  this.embedder.close?.();
38061
38145
  if (this.replyWork.size) {
38062
38146
  let timer;
@@ -9,6 +9,14 @@
9
9
  import type { RecallCapture, RecallRow, RuntimeInboxRewrite, SqliteMlsStateStore } from "@agentvault/connect-mls-store";
10
10
  import { type ServiceLog } from "../../service-log.js";
11
11
  import type { Embedder } from "../embedder/client.js";
12
+ /**
13
+ * Spec §7.1, §20 E1.4 (architect ruling, owner-approved 2026-10-09): the per-turn-kind wall-clock bound on the
14
+ * query embed. A typed turn waits up to 600 ms; a coalesced voice turn at window close up to 250 ms. A result
15
+ * after the bound is discarded (fail closed: no recall), never attached late. The cutoff cancels the job at the
16
+ * shared embedder. Each kind's value is this one line.
17
+ */
18
+ export declare const RECALL_QUERY_TIMEOUT_TEXT_MS = 600;
19
+ export declare const RECALL_QUERY_TIMEOUT_VOICE_MS = 250;
12
20
  export type IndexSkip = "mode" | "audience" | "voice" | "length" | "secret" | "stamp_missing" | "cleared" | "epoch" | "purged" | "duplicate" | "store";
13
21
  export interface RecallTapCapture {
14
22
  readonly generation: number | undefined;
@@ -54,13 +62,17 @@ export declare class RecallBook {
54
62
  private static readonly IDLE_MS;
55
63
  private static readonly TOP_K;
56
64
  private static readonly TEXT_MAX_CHARS;
57
- private static readonly QUERY_TIMEOUT_MS;
58
65
  private readonly caches;
59
66
  private readonly entries;
60
67
  private readonly warming;
61
68
  private same;
62
69
  private nowMs;
63
70
  cacheWarm(conversationId: string): boolean;
71
+ /**
72
+ * Task 27 Step 1: the bytes the warm vector caches hold (a number, content-free; /healthz `recall_cache_bytes`).
73
+ * The backing buffer, not the view: a warm that skipped unreadable rows installs a shorter view of a full buffer.
74
+ */
75
+ cacheBytes(): number;
64
76
  /**
65
77
  * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
66
78
  * a read failure leaves the cache cold (fail closed: no recall).
@@ -81,15 +93,15 @@ export declare class RecallBook {
81
93
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
82
94
  * replaces its purge and mode epochs with the ones captured when the turn
83
95
  * was received (Task 16 fix H-2), so a purge or a mode change after receipt
84
- * refuses too. 150 ms bound on the query embed; a cold cache starts a warm
85
- * and gives nothing.
96
+ * refuses too. `timeoutMs` bounds the query embed (the caller passes its turn kind's bound); a cold cache
97
+ * starts a warm and gives nothing.
86
98
  */
87
99
  prepare(p: {
88
100
  messageId: string;
89
101
  conversationId: string;
90
102
  query: string;
91
103
  excludeIds: ReadonlySet<string>;
92
- timeoutMs?: number;
104
+ timeoutMs: number;
93
105
  receipt?: {
94
106
  purgeEpoch: number;
95
107
  modeEpoch: number;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.28.0";
1
+ export declare const BRIDGE_VERSION = "0.28.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"