@securecoms/agent-bridge 0.28.0 → 0.28.2

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,39 @@ 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.2
802
+
803
+ <!-- [COPY, NEW] this section. Approved by Eric 2026-10-09 ("approve 0.28.2 copy"). -->
804
+
805
+ 0.28.2 keeps semantic memory recall working after quiet periods. Install it in
806
+ place of 0.28.1 and restart the bridge. `@securecoms/agent-runtime` 0.11.0 is
807
+ unchanged.
808
+
809
+ - **After a quiet period.** When the agent has been idle for 30 minutes or
810
+ more, the first private message now waits for the memory search inside the
811
+ same time limit (600 ms typed, 250 ms in a voice call) instead of skipping it.
812
+ If the limit runs out, that message has no recall, and the next one does.
813
+ - **Idle is not an error.** The memory helper process still stops after 30 idle
814
+ minutes to free memory. The bridge no longer logs or reports that as
815
+ unavailable.
816
+
817
+ ## Release notes: 0.28.1
818
+
819
+ <!-- [COPY, NEW] this section. Gate: Eric sign-off before npm publish (release PR). -->
820
+
821
+ 0.28.1 makes semantic memory recall more reliable. Install it in place of
822
+ 0.28.0 and restart the bridge. `@securecoms/agent-runtime` 0.11.0 is unchanged.
823
+
824
+ - **After a restart.** When the model files are already on the machine, the
825
+ bridge loads the owner's private-chat memory at startup, so the first
826
+ messages after a restart get recall. Without the files, the model still
827
+ downloads on the owner's first private message, as before.
828
+ - **Time limit.** The bridge now waits up to 600 ms for a typed message's
829
+ memory search and up to 250 ms during a voice call (it was 150 ms for both).
830
+ - **Idle cache.** A memory cache in use is no longer dropped after 30 minutes.
831
+ - **`/healthz`.** New `recall_cache_bytes`: the bytes of this bridge's memory
832
+ caches, a number only.
833
+
801
834
  ## Release notes: 0.28.0
802
835
 
803
836
  0.28.0 adds semantic memory in the owner's private chat. Install it together
package/dist/bin.js CHANGED
@@ -30085,6 +30085,7 @@ init_manifest();
30085
30085
  // src/memory/embedder/protocol.ts
30086
30086
  import { StringDecoder } from "node:string_decoder";
30087
30087
  var EMBEDDER_PROTOCOL = 1;
30088
+ var EMBEDDER_IDLE_MS = 30 * 6e4;
30088
30089
  var MAX_LINE_BYTES = 69632;
30089
30090
  function decodeVec64(s) {
30090
30091
  const b = Buffer.from(s, "base64");
@@ -30476,6 +30477,7 @@ var INERT_EMBEDDER = Object.freeze({
30476
30477
  ensureStarted: () => {
30477
30478
  },
30478
30479
  filesPresent: () => false,
30480
+ ready: () => Promise.resolve(false),
30479
30481
  embed: () => Promise.reject(new Error("inert embedder"))
30480
30482
  });
30481
30483
  var SERVER_CODES = /* @__PURE__ */ new Set(["busy", "too_long", "worker_reset", "failed"]);
@@ -30505,6 +30507,10 @@ var EmbedderClient = class {
30505
30507
  verifyAttempt = 0;
30506
30508
  fails = 0;
30507
30509
  closed = false;
30510
+ /** When this client last sent a job (or connected, if later): the idle-stop test (cold-path ruling 2026-10-09). */
30511
+ lastActivity = -Infinity;
30512
+ /** ready(ms) waiters: settled on the next transition out of "preparing". */
30513
+ readyWaiters = /* @__PURE__ */ new Set();
30508
30514
  m;
30509
30515
  now;
30510
30516
  log;
@@ -30512,6 +30518,24 @@ var EmbedderClient = class {
30512
30518
  state() {
30513
30519
  return this.st;
30514
30520
  }
30521
+ /** Cold-path ruling (2026-10-09): see Embedder.ready. Pure like state(): never starts, connects or spawns anything. */
30522
+ ready(ms) {
30523
+ if (this.st.state === "ready") return Promise.resolve(true);
30524
+ if (this.st.state !== "preparing" || !(ms > 0)) return Promise.resolve(false);
30525
+ return new Promise((resolve3) => {
30526
+ let t;
30527
+ const w = (ok) => {
30528
+ clearTimeout(t);
30529
+ this.readyWaiters.delete(w);
30530
+ resolve3(ok);
30531
+ };
30532
+ this.readyWaiters.add(w);
30533
+ t = setTimeout(() => w(false), ms);
30534
+ });
30535
+ }
30536
+ settleReady(ok) {
30537
+ for (const w of [...this.readyWaiters]) w(ok);
30538
+ }
30515
30539
  tcp() {
30516
30540
  return (this.o.platform ?? process.platform) === "win32";
30517
30541
  }
@@ -30580,6 +30604,24 @@ var EmbedderClient = class {
30580
30604
  for (const p of this.pending.values()) p.rej(new Error(reason));
30581
30605
  this.pending.clear();
30582
30606
  if (reason !== "verify_failed" && reason !== "embedder_crashed") this.notBefore = Math.max(this.notBefore, this.now() + reconnectDelayMs(++this.fails));
30607
+ this.settleReady(false);
30608
+ }
30609
+ /**
30610
+ * Rule 6, cold-path ruling (2026-10-09): the served socket closed. With no job pending and this client's last job (or
30611
+ * its connect, if later) at least the shared idle constant ago, it is the shared embedder's idle stop, which is not a
30612
+ * failure: back to `off`, no `fails++`, no `notBefore`, no unavailable report, so the next turn's ensureStarted starts
30613
+ * it at once. Anything else (a job pending, or recent use) stays a failure with its backoff.
30614
+ */
30615
+ onServedClose() {
30616
+ if (this.closed) return;
30617
+ if (this.pending.size > 0 || this.now() - this.lastActivity < EMBEDDER_IDLE_MS) return this.fail("embedder_unavailable");
30618
+ this.log("info", "memory.embedder_idle_closed");
30619
+ this.st = { state: "off" };
30620
+ const s = this.sock;
30621
+ this.sock = null;
30622
+ this.chan = null;
30623
+ s?.destroy();
30624
+ this.settleReady(false);
30583
30625
  }
30584
30626
  ensureStarted() {
30585
30627
  if (this.closed || this.st.state === "preparing" || this.st.state === "ready") return;
@@ -30791,9 +30833,11 @@ var EmbedderClient = class {
30791
30833
  this.st = { state: "ready" };
30792
30834
  this.fails = 0;
30793
30835
  this.starts = [];
30836
+ this.lastActivity = this.now();
30794
30837
  s.on("close", () => {
30795
- if (this.sock === s) this.fail("embedder_unavailable");
30838
+ if (this.sock === s) this.onServedClose();
30796
30839
  });
30840
+ this.settleReady(true);
30797
30841
  done(true);
30798
30842
  } catch {
30799
30843
  refuse("embedder_unavailable");
@@ -30827,6 +30871,7 @@ var EmbedderClient = class {
30827
30871
  const id = ++this.seq;
30828
30872
  const s = this.sock;
30829
30873
  const chan = this.chan;
30874
+ this.lastActivity = this.now();
30830
30875
  return new Promise((res, rej) => {
30831
30876
  const signal = opts.signal;
30832
30877
  const onAbort = () => {
@@ -30855,6 +30900,7 @@ var EmbedderClient = class {
30855
30900
  for (const p of this.pending.values()) p.rej(new Error("closed"));
30856
30901
  this.pending.clear();
30857
30902
  this.st = { state: "off" };
30903
+ this.settleReady(false);
30858
30904
  }
30859
30905
  };
30860
30906
 
@@ -30879,6 +30925,8 @@ function rankRecall(query, cache, o) {
30879
30925
 
30880
30926
  // src/memory/recall/recall-book.ts
30881
30927
  var DIMS = 384;
30928
+ var RECALL_QUERY_TIMEOUT_TEXT_MS = 600;
30929
+ var RECALL_QUERY_TIMEOUT_VOICE_MS = 250;
30882
30930
  var RecallBook = class _RecallBook {
30883
30931
  constructor(d) {
30884
30932
  this.d = d;
@@ -30932,10 +30980,12 @@ var RecallBook = class _RecallBook {
30932
30980
  static IDLE_MS = 30 * 6e4;
30933
30981
  static TOP_K = 5;
30934
30982
  static TEXT_MAX_CHARS = 2e3;
30935
- static QUERY_TIMEOUT_MS = 150;
30983
+ /** About 1.5 ms of real opens at the measured ~40 µs per row: the longest a warm holds the event loop. */
30984
+ static WARM_YIELD_ROWS = 32;
30936
30985
  caches = /* @__PURE__ */ new Map();
30937
30986
  entries = /* @__PURE__ */ new Map();
30938
- warming = /* @__PURE__ */ new Set();
30987
+ /** The warm in flight per conversation: a cold turn awaits the same warm instead of starting a second one. */
30988
+ warming = /* @__PURE__ */ new Map();
30939
30989
  same(a, b) {
30940
30990
  return a.clearGeneration === b.clearGeneration && a.modeEpoch === b.modeEpoch && a.purgeEpoch === b.purgeEpoch && a.contentEpoch === b.contentEpoch;
30941
30991
  }
@@ -30945,6 +30995,15 @@ var RecallBook = class _RecallBook {
30945
30995
  cacheWarm(conversationId) {
30946
30996
  return this.caches.has(conversationId);
30947
30997
  }
30998
+ /**
30999
+ * Task 27 Step 1: the bytes the warm vector caches hold (a number, content-free; /healthz `recall_cache_bytes`).
31000
+ * The backing buffer, not the view: a warm that skipped unreadable rows installs a shorter view of a full buffer.
31001
+ */
31002
+ cacheBytes() {
31003
+ let n = 0;
31004
+ for (const c of this.caches.values()) n += c.vecs.buffer.byteLength;
31005
+ return n;
31006
+ }
30948
31007
  /**
30949
31008
  * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
30950
31009
  * a read failure leaves the cache cold (fail closed: no recall).
@@ -30960,15 +31019,23 @@ var RecallBook = class _RecallBook {
30960
31019
  * re-read rows (ids gone are dropped, the few new ids are opened here, under the mutex) and installed with the
30961
31020
  * re-read capture.
30962
31021
  */
30963
- async warm(conversationId) {
30964
- if (this.warming.has(conversationId)) return;
30965
- this.warming.add(conversationId);
31022
+ warm(conversationId) {
31023
+ const inFlight = this.warming.get(conversationId);
31024
+ if (inFlight) return inFlight;
31025
+ const w = this.warmOnce(conversationId).finally(() => {
31026
+ if (this.warming.get(conversationId) === w) this.warming.delete(conversationId);
31027
+ });
31028
+ this.warming.set(conversationId, w);
31029
+ return w;
31030
+ }
31031
+ async warmOnce(conversationId) {
30966
31032
  try {
30967
31033
  const snap = this.d.store.readRecallRows(conversationId);
30968
31034
  const rows = snap.rows.filter((r) => r.modelId === this.d.modelId);
30969
31035
  const opened = /* @__PURE__ */ new Map();
30970
31036
  const failed = /* @__PURE__ */ new Set();
30971
- for (const r of rows) {
31037
+ for (const [i, r] of rows.entries()) {
31038
+ if (i > 0 && i % _RecallBook.WARM_YIELD_ROWS === 0) await new Promise((r2) => setImmediate(r2));
30972
31039
  const v = await this.d.store.openRecallVec(conversationId, r);
30973
31040
  if (!v || v.length !== DIMS) {
30974
31041
  failed.add(r.recallId);
@@ -31010,46 +31077,61 @@ var RecallBook = class _RecallBook {
31010
31077
  if (unreadable > 0) (this.d.log ?? writeServiceLog)("warn", "memory.recall_open_failed", { count: unreadable });
31011
31078
  } catch (e) {
31012
31079
  (this.d.log ?? writeServiceLog)("warn", "memory.recall_warm_failed", {}, e);
31013
- } finally {
31014
- this.warming.delete(conversationId);
31015
31080
  }
31016
31081
  }
31017
31082
  /**
31018
31083
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
31019
31084
  * replaces its purge and mode epochs with the ones captured when the turn
31020
31085
  * 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.
31086
+ * refuses too.
31087
+ *
31088
+ * Cold-path ruling (architect, 2026-10-09; §7.1 and §7.3 errata): ONE deadline, start + `timeoutMs` (the caller
31089
+ * passes its turn kind's bound: 600 ms text, 250 ms voice), covers the ready wait, the warm and the query embed
31090
+ * together; no phase has a budget of its own. A cold embedder is started (ensureStarted) and waited for; a cold or
31091
+ * stale cache is warmed and waited for. Past the deadline there is no recall and nothing attaches late: a warm that
31092
+ * outlives it keeps running and may install (warm enforces H2 itself), but this turn gets no entry.
31023
31093
  */
31024
31094
  async prepare(p) {
31025
31095
  const fresh = this.d.store.recallCapture(p.conversationId);
31026
31096
  const capture = p.receipt ? { ...fresh, purgeEpoch: p.receipt.purgeEpoch, modeEpoch: p.receipt.modeEpoch } : fresh;
31027
31097
  this.entries.delete(p.messageId);
31028
- const now = this.nowMs();
31029
- this.dropIdleCaches(now);
31030
- if (this.d.embedder.state().state !== "ready") {
31031
- this.d.embedder.ensureStarted();
31032
- return;
31033
- }
31034
- const cache = this.caches.get(p.conversationId);
31035
- if (!cache || !this.same(cache.capture, capture)) {
31036
- void this.warm(p.conversationId);
31037
- return;
31038
- }
31098
+ this.dropIdleCaches(this.nowMs());
31099
+ const deadlineAt = Date.now() + p.timeoutMs;
31039
31100
  const ac = new AbortController();
31101
+ let late = false;
31040
31102
  let timer;
31041
- const q = await Promise.race([
31042
- this.d.embedder.embed(p.query, 0, { signal: ac.signal }).catch(() => null),
31043
- new Promise((r) => {
31044
- timer = setTimeout(() => {
31045
- ac.abort();
31046
- r(null);
31047
- }, p.timeoutMs ?? _RecallBook.QUERY_TIMEOUT_MS);
31048
- })
31049
- ]);
31050
- clearTimeout(timer);
31051
- if (!q || q.length !== DIMS) return;
31052
- cache.lastUse = this.nowMs();
31103
+ const expired = new Promise((r) => {
31104
+ timer = setTimeout(() => {
31105
+ late = true;
31106
+ ac.abort();
31107
+ r("expired");
31108
+ }, p.timeoutMs);
31109
+ });
31110
+ try {
31111
+ if (this.d.embedder.state().state !== "ready") {
31112
+ this.d.embedder.ensureStarted();
31113
+ if (await Promise.race([this.d.embedder.ready(Math.max(0, deadlineAt - Date.now())).catch(() => false), expired]) !== true) return;
31114
+ }
31115
+ let cache = this.caches.get(p.conversationId);
31116
+ if (!cache || !this.same(cache.capture, capture)) {
31117
+ const cur = this.d.store.recallCapture(p.conversationId);
31118
+ if (cur.clearGeneration !== capture.clearGeneration || cur.modeEpoch !== capture.modeEpoch || cur.purgeEpoch !== capture.purgeEpoch) return;
31119
+ if (await Promise.race([this.warm(p.conversationId).then(() => "warmed"), expired]) !== "warmed") return;
31120
+ cache = this.caches.get(p.conversationId);
31121
+ if (!cache || !this.same(cache.capture, capture)) return;
31122
+ }
31123
+ cache.lastUse = this.nowMs();
31124
+ const q = await Promise.race([
31125
+ this.d.embedder.embed(p.query, 0, { signal: ac.signal }).catch(() => null),
31126
+ expired.then(() => null)
31127
+ ]);
31128
+ if (!q || q.length !== DIMS) return;
31129
+ await Promise.race([this.attach(p, capture, cache, q, () => late), expired]);
31130
+ } finally {
31131
+ clearTimeout(timer);
31132
+ }
31133
+ }
31134
+ async attach(p, capture, cache, q, late) {
31053
31135
  const ranked = rankRecall(
31054
31136
  q,
31055
31137
  { vecs: cache.vecs, dims: DIMS, messageIds: cache.messageIds, recallIds: cache.recallIds },
@@ -31071,7 +31153,7 @@ var RecallBook = class _RecallBook {
31071
31153
  items.push({ id: r.recallId, text, sent_at: row.sentAt });
31072
31154
  }
31073
31155
  if (unreadable > 0) (this.d.log ?? writeServiceLog)("warn", "memory.recall_open_failed", { count: unreadable });
31074
- if (items.length === 0) return;
31156
+ if (items.length === 0 || late()) return;
31075
31157
  if (this.entries.size >= _RecallBook.MAP_MAX) this.entries.delete(this.entries.keys().next().value);
31076
31158
  this.entries.set(p.messageId, { conversationId: p.conversationId, capture, items });
31077
31159
  }
@@ -33147,6 +33229,19 @@ var BridgeAgent = class _BridgeAgent {
33147
33229
  recallStageEpochs = /* @__PURE__ */ new Map();
33148
33230
  /** 7b Task 16: in-flight fire-and-forget index taps; close() waits for them (bounded). */
33149
33231
  recallTapWork = /* @__PURE__ */ new Set();
33232
+ /**
33233
+ * §13.5 live check (2026-10-09, cold start): set when an apply turns recall on (the boot apply included), cleared
33234
+ * once the maintenance tick has started the DM prewarm, or when recall is off at a tick.
33235
+ */
33236
+ recallPrewarmPending = false;
33237
+ /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
33238
+ recallPrewarmWork = null;
33239
+ /**
33240
+ * Cold-path ruling B (2026-10-09): conversations whose turn passed Q1/Q2, for the maintenance tick to warm once the
33241
+ * embedder is ready (runRecallPrewarm). Bounded; dropped whenever a tick finds recall off.
33242
+ */
33243
+ recallPrewarmTargets = /* @__PURE__ */ new Set();
33244
+ static RECALL_PREWARM_TARGETS_MAX = 256;
33150
33245
  /**
33151
33246
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
33152
33247
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -35346,7 +35441,9 @@ var BridgeAgent = class _BridgeAgent {
35346
35441
  await this.notes.exclusive(async () => {
35347
35442
  if (this.store.applyRecallMode("semantic", mem.recallDays).purged > 0) this.onRecallRetentionChangedLocked();
35348
35443
  });
35444
+ const turnedOn = !this.recallConfig.on;
35349
35445
  this.recallConfig = { on: true };
35446
+ if (turnedOn) this.armRecallPrewarm();
35350
35447
  if (this.cfg.modelSource === "download" && !this.modelDownloadNoticeLogged) {
35351
35448
  this.modelDownloadNoticeLogged = true;
35352
35449
  writeServiceLog("info", "memory.model_download_enabled");
@@ -35367,6 +35464,59 @@ var BridgeAgent = class _BridgeAgent {
35367
35464
  writeServiceLog("warn", "memory.recall_mode_failed", {}, e);
35368
35465
  }
35369
35466
  }
35467
+ /**
35468
+ * §13.5 live check (2026-10-09, cold start). After a restart in semantic mode turn 1 got no recall (the embedder
35469
+ * was not ready: ensureStarted, return) and turn 2 none either (no cache: a background warm, return). So when an
35470
+ * apply turns recall on (the boot apply included) and the model files are on disk, the embedder is started here,
35471
+ * off the turn path; the
35472
+ * maintenance tick warms the DM caches once it is ready (runRecallPrewarm). Synchronous and never throws:
35473
+ * ensureStarted only schedules the start, so boot never waits for it.
35474
+ */
35475
+ armRecallPrewarm() {
35476
+ try {
35477
+ if (!this.recallProviderOn()) return;
35478
+ if (!this.embedder.filesPresent()) return;
35479
+ this.recallPrewarmPending = true;
35480
+ this.embedder.ensureStarted();
35481
+ } catch (e) {
35482
+ writeServiceLog("warn", "memory.recall_prewarm_failed", {}, e);
35483
+ }
35484
+ }
35485
+ /**
35486
+ * The maintenance tick's half of the cold-start prewarm: once the embedder is ready (no cache RAM is spent on an
35487
+ * embedder that may never come up), warm each DM conversation, one at a time, detached from the tick. DM is the
35488
+ * persisted is_dm === true, the rule the membership sweep uses (audit r1 M6); a vault channel is never one. The
35489
+ * prewarm only opens vectors through RecallBook.warm, which snapshots the capture first and installs nothing
35490
+ * across a clear, a mode change or a purge (H2, M-A3). It indexes nothing and prepares nothing. Recall turned off
35491
+ * before or during it stops it (I1, Q1). Never throws.
35492
+ */
35493
+ runRecallPrewarm() {
35494
+ if (!this.recallPrewarmPending && this.recallPrewarmTargets.size === 0 || this.recallPrewarmWork || this.closingDown) return;
35495
+ if (this.embedder.state().state !== "ready") return;
35496
+ const all = this.recallPrewarmPending;
35497
+ this.recallPrewarmPending = false;
35498
+ const targets = [...this.recallPrewarmTargets];
35499
+ this.recallPrewarmTargets.clear();
35500
+ this.recallPrewarmWork = (async () => {
35501
+ try {
35502
+ const isDm = (c) => !this.vaultChannelIds.has(c) && this.store.getChannelMeta(c)?.isDm === true;
35503
+ const dms = [.../* @__PURE__ */ new Set([...targets, ...all ? this.store.memoryConversationIds() : []])].filter(isDm);
35504
+ for (const c of dms) {
35505
+ if (this.closingDown || !this.recallProviderOn()) return;
35506
+ if (this.recall.cacheWarm(c)) continue;
35507
+ await this.recall.warm(c);
35508
+ }
35509
+ } catch (e) {
35510
+ writeServiceLog("warn", "memory.recall_prewarm_failed", {}, e);
35511
+ }
35512
+ })().finally(() => {
35513
+ this.recallPrewarmWork = null;
35514
+ });
35515
+ }
35516
+ /** @internal test seam: resolves once the DM prewarm in flight, if any, has settled. */
35517
+ async settleRecallPrewarm() {
35518
+ await this.recallPrewarmWork;
35519
+ }
35370
35520
  /** A retention change or a prune removed rows: drop the vector caches. Called only inside notes.exclusive. */
35371
35521
  onRecallRetentionChangedLocked() {
35372
35522
  this.recall.dropAllCachesLocked();
@@ -35376,8 +35526,8 @@ var BridgeAgent = class _BridgeAgent {
35376
35526
  this.recall.dropAllLocked();
35377
35527
  }
35378
35528
  /**
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
35529
+ * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the turn kind's
35530
+ * bound inside RecallBook.prepare (600 ms text, 250 ms voice; ruling 2026-10-09). Called beside prepareNotesForTurn, before the
35381
35531
  * turn is staged. The receipt epochs (Task 16 fix H-2) narrow the capture:
35382
35532
  * the in-cycle receipt, else the persisted staging marks; marks that exist
35383
35533
  * but lack an epoch give no recall (fail closed). Never throws.
@@ -35385,11 +35535,13 @@ var BridgeAgent = class _BridgeAgent {
35385
35535
  async prepareRecallForTurn(t) {
35386
35536
  try {
35387
35537
  if (!this.recallProviderOn() || !this.notesReadable(t.authorization)) return;
35538
+ if (this.recallPrewarmTargets.size < _BridgeAgent.RECALL_PREWARM_TARGETS_MAX) this.recallPrewarmTargets.add(t.channelId);
35388
35539
  const marks = this.recallReceipt.get(t.messageId) ?? this.recallStageEpochs.get(t.messageId);
35389
35540
  if (marks !== void 0 && (marks.purgeEpoch === void 0 || marks.modeEpoch === void 0)) return;
35390
35541
  const receipt = marks === void 0 ? void 0 : { purgeEpoch: marks.purgeEpoch, modeEpoch: marks.modeEpoch };
35391
35542
  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 } : {} });
35543
+ const timeoutMs = t.kind === "voice" ? RECALL_QUERY_TIMEOUT_VOICE_MS : RECALL_QUERY_TIMEOUT_TEXT_MS;
35544
+ await this.recall.prepare({ messageId: t.messageId, conversationId: t.channelId, query: t.text, excludeIds: exclude, timeoutMs, ...receipt ? { receipt } : {} });
35393
35545
  } catch (e) {
35394
35546
  writeServiceLog("warn", "memory.recall_prepare_failed", {}, e);
35395
35547
  }
@@ -35551,8 +35703,11 @@ var BridgeAgent = class _BridgeAgent {
35551
35703
  }
35552
35704
  if (!this.recallProviderOn()) {
35553
35705
  this.reembedHot = false;
35706
+ this.recallPrewarmPending = false;
35707
+ this.recallPrewarmTargets.clear();
35554
35708
  return;
35555
35709
  }
35710
+ this.runRecallPrewarm();
35556
35711
  if (!this.reembedHot && n % 60 !== 0) return;
35557
35712
  try {
35558
35713
  this.reembedHot = await this.reembed.tick() > 0;
@@ -37344,7 +37499,10 @@ var BridgeAgent = class _BridgeAgent {
37344
37499
  const turnText = parsed.text;
37345
37500
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
37346
37501
  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 });
37502
+ if (!coalescible) {
37503
+ const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
37504
+ await this.prepareRecallForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
37505
+ }
37348
37506
  let fastPathRoomBlocked = false;
37349
37507
  if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
37350
37508
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
@@ -37428,7 +37586,7 @@ var BridgeAgent = class _BridgeAgent {
37428
37586
  const [firstId, ...tailIds] = win.ids;
37429
37587
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
37430
37588
  const text = win.parts.join(" ");
37431
- await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization });
37589
+ await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
37432
37590
  const merged = { ...held, text };
37433
37591
  void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
37434
37592
  let roomBlocked = false;
@@ -38174,6 +38332,7 @@ var BridgeAgent = class _BridgeAgent {
38174
38332
  retired_drained: this.provider.retiredDrainedCount(),
38175
38333
  // INV-CUSTODY-DURABLE §5.C.3: custody health.
38176
38334
  expired_drained: this.provider.expiredDrainedCount(),
38335
+ recall_cache_bytes: this.recall.cacheBytes(),
38177
38336
  custody_persist_failing: this.provider.custodyHealth().persistFailing,
38178
38337
  custody_persist_failures: this.provider.custodyHealth().persistFailures,
38179
38338
  custody_read_failing: this.provider.custodyHealth().readFailing,
@@ -38307,6 +38466,16 @@ var BridgeAgent = class _BridgeAgent {
38307
38466
  ]);
38308
38467
  if (timer) clearTimeout(timer);
38309
38468
  }
38469
+ if (this.recallPrewarmWork) {
38470
+ let timer;
38471
+ await Promise.race([
38472
+ this.recallPrewarmWork,
38473
+ new Promise((resolve3) => {
38474
+ timer = setTimeout(resolve3, _BridgeAgent.GRACEFUL_DRAIN_MS);
38475
+ })
38476
+ ]);
38477
+ if (timer) clearTimeout(timer);
38478
+ }
38310
38479
  this.embedder.close?.();
38311
38480
  if (this.replyWork.size) {
38312
38481
  let timer;
@@ -1192,6 +1192,19 @@ 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;
1202
+ /**
1203
+ * Cold-path ruling B (2026-10-09): conversations whose turn passed Q1/Q2, for the maintenance tick to warm once the
1204
+ * embedder is ready (runRecallPrewarm). Bounded; dropped whenever a tick finds recall off.
1205
+ */
1206
+ private readonly recallPrewarmTargets;
1207
+ private static readonly RECALL_PREWARM_TARGETS_MAX;
1195
1208
  /**
1196
1209
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
1197
1210
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -2001,13 +2014,33 @@ export declare class BridgeAgent {
2001
2014
  private applyPayloadMemory;
2002
2015
  /** Spec §5.3 (H3): mode state lives in the store; level-triggered after every refetch. Never throws. */
2003
2016
  private applyRecallModeFromPayload;
2017
+ /**
2018
+ * §13.5 live check (2026-10-09, cold start). After a restart in semantic mode turn 1 got no recall (the embedder
2019
+ * was not ready: ensureStarted, return) and turn 2 none either (no cache: a background warm, return). So when an
2020
+ * apply turns recall on (the boot apply included) and the model files are on disk, the embedder is started here,
2021
+ * off the turn path; the
2022
+ * maintenance tick warms the DM caches once it is ready (runRecallPrewarm). Synchronous and never throws:
2023
+ * ensureStarted only schedules the start, so boot never waits for it.
2024
+ */
2025
+ private armRecallPrewarm;
2026
+ /**
2027
+ * The maintenance tick's half of the cold-start prewarm: once the embedder is ready (no cache RAM is spent on an
2028
+ * embedder that may never come up), warm each DM conversation, one at a time, detached from the tick. DM is the
2029
+ * persisted is_dm === true, the rule the membership sweep uses (audit r1 M6); a vault channel is never one. The
2030
+ * prewarm only opens vectors through RecallBook.warm, which snapshots the capture first and installs nothing
2031
+ * across a clear, a mode change or a purge (H2, M-A3). It indexes nothing and prepares nothing. Recall turned off
2032
+ * before or during it stops it (I1, Q1). Never throws.
2033
+ */
2034
+ private runRecallPrewarm;
2035
+ /** @internal test seam: resolves once the DM prewarm in flight, if any, has settled. */
2036
+ settleRecallPrewarm(): Promise<void>;
2004
2037
  /** A retention change or a prune removed rows: drop the vector caches. Called only inside notes.exclusive. */
2005
2038
  private onRecallRetentionChangedLocked;
2006
2039
  /** Markdown mode deleted every row: drop the caches and the per-message map. Called only inside notes.exclusive. */
2007
2040
  private onRecallPurgedAllLocked;
2008
2041
  /**
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
2042
+ * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the turn kind's
2043
+ * bound inside RecallBook.prepare (600 ms text, 250 ms voice; ruling 2026-10-09). Called beside prepareNotesForTurn, before the
2011
2044
  * turn is staged. The receipt epochs (Task 16 fix H-2) narrow the capture:
2012
2045
  * the in-cycle receipt, else the persisted staging marks; marks that exist
2013
2046
  * but lack an epoch give no recall (fail closed). Never throws.
@@ -2877,6 +2910,9 @@ export declare class BridgeAgent {
2877
2910
  retired_drained: number;
2878
2911
  /** INV-CUSTODY-DURABLE §5.C.6: rows silently drained past expires_at. */
2879
2912
  expired_drained: number;
2913
+ /** 7b Task 27 Step 1: bytes held by this bridge's warm recall vector caches. A number, content-free; the fleet
2914
+ * RAM script (scripts/ops/semantic-memory-ram-budget.mjs) sums it with the shared embedder's RSS. */
2915
+ recall_cache_bytes: number;
2880
2916
  /** INV-CUSTODY-DURABLE §5.C.3: a decrypted, un-acked row is memory-only
2881
2917
  * (its durable write failed); a restart now would lose it. */
2882
2918
  custody_persist_failing: boolean;
package/dist/embedder.js CHANGED
@@ -58,6 +58,7 @@ import { join as join2 } from "node:path";
58
58
  import { StringDecoder } from "node:string_decoder";
59
59
  var EMBEDDER_PROTOCOL = 1;
60
60
  var MAX_TEXT_BYTES = 8192;
61
+ var EMBEDDER_IDLE_MS = 30 * 6e4;
61
62
  var MAX_LINE_BYTES = 69632;
62
63
  function encodeVec64(v) {
63
64
  const b = Buffer.alloc(v.length * 4);
@@ -1216,7 +1217,7 @@ async function serve(o, log, p, lock, token, published, server, tcp, publish) {
1216
1217
  let stopped = false;
1217
1218
  const touchIdle = () => {
1218
1219
  clearTimeout(idleTimer);
1219
- idleTimer = setTimeout(() => void stop("idle"), o.idleMs ?? 30 * 6e4);
1220
+ idleTimer = setTimeout(() => void stop("idle"), o.idleMs ?? EMBEDDER_IDLE_MS);
1220
1221
  };
1221
1222
  const send = (conn, f2) => {
1222
1223
  const s = conns.get(conn);
package/dist/index.js CHANGED
@@ -29580,6 +29580,7 @@ var TOKENIZER_OPTIONS = Object.freeze({
29580
29580
  // src/memory/embedder/protocol.ts
29581
29581
  import { StringDecoder } from "node:string_decoder";
29582
29582
  var EMBEDDER_PROTOCOL = 1;
29583
+ var EMBEDDER_IDLE_MS = 30 * 6e4;
29583
29584
  var MAX_LINE_BYTES = 69632;
29584
29585
  function decodeVec64(s) {
29585
29586
  const b = Buffer.from(s, "base64");
@@ -29970,6 +29971,7 @@ var INERT_EMBEDDER = Object.freeze({
29970
29971
  ensureStarted: () => {
29971
29972
  },
29972
29973
  filesPresent: () => false,
29974
+ ready: () => Promise.resolve(false),
29973
29975
  embed: () => Promise.reject(new Error("inert embedder"))
29974
29976
  });
29975
29977
  var SERVER_CODES = /* @__PURE__ */ new Set(["busy", "too_long", "worker_reset", "failed"]);
@@ -29999,6 +30001,10 @@ var EmbedderClient = class {
29999
30001
  verifyAttempt = 0;
30000
30002
  fails = 0;
30001
30003
  closed = false;
30004
+ /** When this client last sent a job (or connected, if later): the idle-stop test (cold-path ruling 2026-10-09). */
30005
+ lastActivity = -Infinity;
30006
+ /** ready(ms) waiters: settled on the next transition out of "preparing". */
30007
+ readyWaiters = /* @__PURE__ */ new Set();
30002
30008
  m;
30003
30009
  now;
30004
30010
  log;
@@ -30006,6 +30012,24 @@ var EmbedderClient = class {
30006
30012
  state() {
30007
30013
  return this.st;
30008
30014
  }
30015
+ /** Cold-path ruling (2026-10-09): see Embedder.ready. Pure like state(): never starts, connects or spawns anything. */
30016
+ ready(ms) {
30017
+ if (this.st.state === "ready") return Promise.resolve(true);
30018
+ if (this.st.state !== "preparing" || !(ms > 0)) return Promise.resolve(false);
30019
+ return new Promise((resolve) => {
30020
+ let t;
30021
+ const w = (ok) => {
30022
+ clearTimeout(t);
30023
+ this.readyWaiters.delete(w);
30024
+ resolve(ok);
30025
+ };
30026
+ this.readyWaiters.add(w);
30027
+ t = setTimeout(() => w(false), ms);
30028
+ });
30029
+ }
30030
+ settleReady(ok) {
30031
+ for (const w of [...this.readyWaiters]) w(ok);
30032
+ }
30009
30033
  tcp() {
30010
30034
  return (this.o.platform ?? process.platform) === "win32";
30011
30035
  }
@@ -30074,6 +30098,24 @@ var EmbedderClient = class {
30074
30098
  for (const p of this.pending.values()) p.rej(new Error(reason));
30075
30099
  this.pending.clear();
30076
30100
  if (reason !== "verify_failed" && reason !== "embedder_crashed") this.notBefore = Math.max(this.notBefore, this.now() + reconnectDelayMs(++this.fails));
30101
+ this.settleReady(false);
30102
+ }
30103
+ /**
30104
+ * Rule 6, cold-path ruling (2026-10-09): the served socket closed. With no job pending and this client's last job (or
30105
+ * its connect, if later) at least the shared idle constant ago, it is the shared embedder's idle stop, which is not a
30106
+ * failure: back to `off`, no `fails++`, no `notBefore`, no unavailable report, so the next turn's ensureStarted starts
30107
+ * it at once. Anything else (a job pending, or recent use) stays a failure with its backoff.
30108
+ */
30109
+ onServedClose() {
30110
+ if (this.closed) return;
30111
+ if (this.pending.size > 0 || this.now() - this.lastActivity < EMBEDDER_IDLE_MS) return this.fail("embedder_unavailable");
30112
+ this.log("info", "memory.embedder_idle_closed");
30113
+ this.st = { state: "off" };
30114
+ const s = this.sock;
30115
+ this.sock = null;
30116
+ this.chan = null;
30117
+ s?.destroy();
30118
+ this.settleReady(false);
30077
30119
  }
30078
30120
  ensureStarted() {
30079
30121
  if (this.closed || this.st.state === "preparing" || this.st.state === "ready") return;
@@ -30285,9 +30327,11 @@ var EmbedderClient = class {
30285
30327
  this.st = { state: "ready" };
30286
30328
  this.fails = 0;
30287
30329
  this.starts = [];
30330
+ this.lastActivity = this.now();
30288
30331
  s.on("close", () => {
30289
- if (this.sock === s) this.fail("embedder_unavailable");
30332
+ if (this.sock === s) this.onServedClose();
30290
30333
  });
30334
+ this.settleReady(true);
30291
30335
  done(true);
30292
30336
  } catch {
30293
30337
  refuse("embedder_unavailable");
@@ -30321,6 +30365,7 @@ var EmbedderClient = class {
30321
30365
  const id = ++this.seq;
30322
30366
  const s = this.sock;
30323
30367
  const chan = this.chan;
30368
+ this.lastActivity = this.now();
30324
30369
  return new Promise((res, rej) => {
30325
30370
  const signal = opts.signal;
30326
30371
  const onAbort = () => {
@@ -30349,6 +30394,7 @@ var EmbedderClient = class {
30349
30394
  for (const p of this.pending.values()) p.rej(new Error("closed"));
30350
30395
  this.pending.clear();
30351
30396
  this.st = { state: "off" };
30397
+ this.settleReady(false);
30352
30398
  }
30353
30399
  };
30354
30400
 
@@ -30369,6 +30415,8 @@ function rankRecall(query, cache, o) {
30369
30415
 
30370
30416
  // src/memory/recall/recall-book.ts
30371
30417
  var DIMS = 384;
30418
+ var RECALL_QUERY_TIMEOUT_TEXT_MS = 600;
30419
+ var RECALL_QUERY_TIMEOUT_VOICE_MS = 250;
30372
30420
  var RecallBook = class _RecallBook {
30373
30421
  constructor(d) {
30374
30422
  this.d = d;
@@ -30422,10 +30470,12 @@ var RecallBook = class _RecallBook {
30422
30470
  static IDLE_MS = 30 * 6e4;
30423
30471
  static TOP_K = 5;
30424
30472
  static TEXT_MAX_CHARS = 2e3;
30425
- static QUERY_TIMEOUT_MS = 150;
30473
+ /** About 1.5 ms of real opens at the measured ~40 µs per row: the longest a warm holds the event loop. */
30474
+ static WARM_YIELD_ROWS = 32;
30426
30475
  caches = /* @__PURE__ */ new Map();
30427
30476
  entries = /* @__PURE__ */ new Map();
30428
- warming = /* @__PURE__ */ new Set();
30477
+ /** The warm in flight per conversation: a cold turn awaits the same warm instead of starting a second one. */
30478
+ warming = /* @__PURE__ */ new Map();
30429
30479
  same(a, b) {
30430
30480
  return a.clearGeneration === b.clearGeneration && a.modeEpoch === b.modeEpoch && a.purgeEpoch === b.purgeEpoch && a.contentEpoch === b.contentEpoch;
30431
30481
  }
@@ -30435,6 +30485,15 @@ var RecallBook = class _RecallBook {
30435
30485
  cacheWarm(conversationId) {
30436
30486
  return this.caches.has(conversationId);
30437
30487
  }
30488
+ /**
30489
+ * Task 27 Step 1: the bytes the warm vector caches hold (a number, content-free; /healthz `recall_cache_bytes`).
30490
+ * The backing buffer, not the view: a warm that skipped unreadable rows installs a shorter view of a full buffer.
30491
+ */
30492
+ cacheBytes() {
30493
+ let n = 0;
30494
+ for (const c of this.caches.values()) n += c.vecs.buffer.byteLength;
30495
+ return n;
30496
+ }
30438
30497
  /**
30439
30498
  * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
30440
30499
  * a read failure leaves the cache cold (fail closed: no recall).
@@ -30450,15 +30509,23 @@ var RecallBook = class _RecallBook {
30450
30509
  * re-read rows (ids gone are dropped, the few new ids are opened here, under the mutex) and installed with the
30451
30510
  * re-read capture.
30452
30511
  */
30453
- async warm(conversationId) {
30454
- if (this.warming.has(conversationId)) return;
30455
- this.warming.add(conversationId);
30512
+ warm(conversationId) {
30513
+ const inFlight = this.warming.get(conversationId);
30514
+ if (inFlight) return inFlight;
30515
+ const w = this.warmOnce(conversationId).finally(() => {
30516
+ if (this.warming.get(conversationId) === w) this.warming.delete(conversationId);
30517
+ });
30518
+ this.warming.set(conversationId, w);
30519
+ return w;
30520
+ }
30521
+ async warmOnce(conversationId) {
30456
30522
  try {
30457
30523
  const snap = this.d.store.readRecallRows(conversationId);
30458
30524
  const rows = snap.rows.filter((r) => r.modelId === this.d.modelId);
30459
30525
  const opened = /* @__PURE__ */ new Map();
30460
30526
  const failed = /* @__PURE__ */ new Set();
30461
- for (const r of rows) {
30527
+ for (const [i, r] of rows.entries()) {
30528
+ if (i > 0 && i % _RecallBook.WARM_YIELD_ROWS === 0) await new Promise((r2) => setImmediate(r2));
30462
30529
  const v = await this.d.store.openRecallVec(conversationId, r);
30463
30530
  if (!v || v.length !== DIMS) {
30464
30531
  failed.add(r.recallId);
@@ -30500,46 +30567,61 @@ var RecallBook = class _RecallBook {
30500
30567
  if (unreadable > 0) (this.d.log ?? writeServiceLog2)("warn", "memory.recall_open_failed", { count: unreadable });
30501
30568
  } catch (e) {
30502
30569
  (this.d.log ?? writeServiceLog2)("warn", "memory.recall_warm_failed", {}, e);
30503
- } finally {
30504
- this.warming.delete(conversationId);
30505
30570
  }
30506
30571
  }
30507
30572
  /**
30508
30573
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
30509
30574
  * replaces its purge and mode epochs with the ones captured when the turn
30510
30575
  * 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.
30576
+ * refuses too.
30577
+ *
30578
+ * Cold-path ruling (architect, 2026-10-09; §7.1 and §7.3 errata): ONE deadline, start + `timeoutMs` (the caller
30579
+ * passes its turn kind's bound: 600 ms text, 250 ms voice), covers the ready wait, the warm and the query embed
30580
+ * together; no phase has a budget of its own. A cold embedder is started (ensureStarted) and waited for; a cold or
30581
+ * stale cache is warmed and waited for. Past the deadline there is no recall and nothing attaches late: a warm that
30582
+ * outlives it keeps running and may install (warm enforces H2 itself), but this turn gets no entry.
30513
30583
  */
30514
30584
  async prepare(p) {
30515
30585
  const fresh = this.d.store.recallCapture(p.conversationId);
30516
30586
  const capture = p.receipt ? { ...fresh, purgeEpoch: p.receipt.purgeEpoch, modeEpoch: p.receipt.modeEpoch } : fresh;
30517
30587
  this.entries.delete(p.messageId);
30518
- const now = this.nowMs();
30519
- this.dropIdleCaches(now);
30520
- if (this.d.embedder.state().state !== "ready") {
30521
- this.d.embedder.ensureStarted();
30522
- return;
30523
- }
30524
- const cache = this.caches.get(p.conversationId);
30525
- if (!cache || !this.same(cache.capture, capture)) {
30526
- void this.warm(p.conversationId);
30527
- return;
30528
- }
30588
+ this.dropIdleCaches(this.nowMs());
30589
+ const deadlineAt = Date.now() + p.timeoutMs;
30529
30590
  const ac = new AbortController();
30591
+ let late = false;
30530
30592
  let timer;
30531
- const q = await Promise.race([
30532
- this.d.embedder.embed(p.query, 0, { signal: ac.signal }).catch(() => null),
30533
- new Promise((r) => {
30534
- timer = setTimeout(() => {
30535
- ac.abort();
30536
- r(null);
30537
- }, p.timeoutMs ?? _RecallBook.QUERY_TIMEOUT_MS);
30538
- })
30539
- ]);
30540
- clearTimeout(timer);
30541
- if (!q || q.length !== DIMS) return;
30542
- cache.lastUse = this.nowMs();
30593
+ const expired = new Promise((r) => {
30594
+ timer = setTimeout(() => {
30595
+ late = true;
30596
+ ac.abort();
30597
+ r("expired");
30598
+ }, p.timeoutMs);
30599
+ });
30600
+ try {
30601
+ if (this.d.embedder.state().state !== "ready") {
30602
+ this.d.embedder.ensureStarted();
30603
+ if (await Promise.race([this.d.embedder.ready(Math.max(0, deadlineAt - Date.now())).catch(() => false), expired]) !== true) return;
30604
+ }
30605
+ let cache = this.caches.get(p.conversationId);
30606
+ if (!cache || !this.same(cache.capture, capture)) {
30607
+ const cur = this.d.store.recallCapture(p.conversationId);
30608
+ if (cur.clearGeneration !== capture.clearGeneration || cur.modeEpoch !== capture.modeEpoch || cur.purgeEpoch !== capture.purgeEpoch) return;
30609
+ if (await Promise.race([this.warm(p.conversationId).then(() => "warmed"), expired]) !== "warmed") return;
30610
+ cache = this.caches.get(p.conversationId);
30611
+ if (!cache || !this.same(cache.capture, capture)) return;
30612
+ }
30613
+ cache.lastUse = this.nowMs();
30614
+ const q = await Promise.race([
30615
+ this.d.embedder.embed(p.query, 0, { signal: ac.signal }).catch(() => null),
30616
+ expired.then(() => null)
30617
+ ]);
30618
+ if (!q || q.length !== DIMS) return;
30619
+ await Promise.race([this.attach(p, capture, cache, q, () => late), expired]);
30620
+ } finally {
30621
+ clearTimeout(timer);
30622
+ }
30623
+ }
30624
+ async attach(p, capture, cache, q, late) {
30543
30625
  const ranked = rankRecall(
30544
30626
  q,
30545
30627
  { vecs: cache.vecs, dims: DIMS, messageIds: cache.messageIds, recallIds: cache.recallIds },
@@ -30561,7 +30643,7 @@ var RecallBook = class _RecallBook {
30561
30643
  items.push({ id: r.recallId, text, sent_at: row.sentAt });
30562
30644
  }
30563
30645
  if (unreadable > 0) (this.d.log ?? writeServiceLog2)("warn", "memory.recall_open_failed", { count: unreadable });
30564
- if (items.length === 0) return;
30646
+ if (items.length === 0 || late()) return;
30565
30647
  if (this.entries.size >= _RecallBook.MAP_MAX) this.entries.delete(this.entries.keys().next().value);
30566
30648
  this.entries.set(p.messageId, { conversationId: p.conversationId, capture, items });
30567
30649
  }
@@ -32897,6 +32979,19 @@ var BridgeAgent = class _BridgeAgent {
32897
32979
  recallStageEpochs = /* @__PURE__ */ new Map();
32898
32980
  /** 7b Task 16: in-flight fire-and-forget index taps; close() waits for them (bounded). */
32899
32981
  recallTapWork = /* @__PURE__ */ new Set();
32982
+ /**
32983
+ * §13.5 live check (2026-10-09, cold start): set when an apply turns recall on (the boot apply included), cleared
32984
+ * once the maintenance tick has started the DM prewarm, or when recall is off at a tick.
32985
+ */
32986
+ recallPrewarmPending = false;
32987
+ /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
32988
+ recallPrewarmWork = null;
32989
+ /**
32990
+ * Cold-path ruling B (2026-10-09): conversations whose turn passed Q1/Q2, for the maintenance tick to warm once the
32991
+ * embedder is ready (runRecallPrewarm). Bounded; dropped whenever a tick finds recall off.
32992
+ */
32993
+ recallPrewarmTargets = /* @__PURE__ */ new Set();
32994
+ static RECALL_PREWARM_TARGETS_MAX = 256;
32900
32995
  /**
32901
32996
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
32902
32997
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -35096,7 +35191,9 @@ var BridgeAgent = class _BridgeAgent {
35096
35191
  await this.notes.exclusive(async () => {
35097
35192
  if (this.store.applyRecallMode("semantic", mem.recallDays).purged > 0) this.onRecallRetentionChangedLocked();
35098
35193
  });
35194
+ const turnedOn = !this.recallConfig.on;
35099
35195
  this.recallConfig = { on: true };
35196
+ if (turnedOn) this.armRecallPrewarm();
35100
35197
  if (this.cfg.modelSource === "download" && !this.modelDownloadNoticeLogged) {
35101
35198
  this.modelDownloadNoticeLogged = true;
35102
35199
  writeServiceLog2("info", "memory.model_download_enabled");
@@ -35117,6 +35214,59 @@ var BridgeAgent = class _BridgeAgent {
35117
35214
  writeServiceLog2("warn", "memory.recall_mode_failed", {}, e);
35118
35215
  }
35119
35216
  }
35217
+ /**
35218
+ * §13.5 live check (2026-10-09, cold start). After a restart in semantic mode turn 1 got no recall (the embedder
35219
+ * was not ready: ensureStarted, return) and turn 2 none either (no cache: a background warm, return). So when an
35220
+ * apply turns recall on (the boot apply included) and the model files are on disk, the embedder is started here,
35221
+ * off the turn path; the
35222
+ * maintenance tick warms the DM caches once it is ready (runRecallPrewarm). Synchronous and never throws:
35223
+ * ensureStarted only schedules the start, so boot never waits for it.
35224
+ */
35225
+ armRecallPrewarm() {
35226
+ try {
35227
+ if (!this.recallProviderOn()) return;
35228
+ if (!this.embedder.filesPresent()) return;
35229
+ this.recallPrewarmPending = true;
35230
+ this.embedder.ensureStarted();
35231
+ } catch (e) {
35232
+ writeServiceLog2("warn", "memory.recall_prewarm_failed", {}, e);
35233
+ }
35234
+ }
35235
+ /**
35236
+ * The maintenance tick's half of the cold-start prewarm: once the embedder is ready (no cache RAM is spent on an
35237
+ * embedder that may never come up), warm each DM conversation, one at a time, detached from the tick. DM is the
35238
+ * persisted is_dm === true, the rule the membership sweep uses (audit r1 M6); a vault channel is never one. The
35239
+ * prewarm only opens vectors through RecallBook.warm, which snapshots the capture first and installs nothing
35240
+ * across a clear, a mode change or a purge (H2, M-A3). It indexes nothing and prepares nothing. Recall turned off
35241
+ * before or during it stops it (I1, Q1). Never throws.
35242
+ */
35243
+ runRecallPrewarm() {
35244
+ if (!this.recallPrewarmPending && this.recallPrewarmTargets.size === 0 || this.recallPrewarmWork || this.closingDown) return;
35245
+ if (this.embedder.state().state !== "ready") return;
35246
+ const all = this.recallPrewarmPending;
35247
+ this.recallPrewarmPending = false;
35248
+ const targets = [...this.recallPrewarmTargets];
35249
+ this.recallPrewarmTargets.clear();
35250
+ this.recallPrewarmWork = (async () => {
35251
+ try {
35252
+ const isDm = (c) => !this.vaultChannelIds.has(c) && this.store.getChannelMeta(c)?.isDm === true;
35253
+ const dms = [.../* @__PURE__ */ new Set([...targets, ...all ? this.store.memoryConversationIds() : []])].filter(isDm);
35254
+ for (const c of dms) {
35255
+ if (this.closingDown || !this.recallProviderOn()) return;
35256
+ if (this.recall.cacheWarm(c)) continue;
35257
+ await this.recall.warm(c);
35258
+ }
35259
+ } catch (e) {
35260
+ writeServiceLog2("warn", "memory.recall_prewarm_failed", {}, e);
35261
+ }
35262
+ })().finally(() => {
35263
+ this.recallPrewarmWork = null;
35264
+ });
35265
+ }
35266
+ /** @internal test seam: resolves once the DM prewarm in flight, if any, has settled. */
35267
+ async settleRecallPrewarm() {
35268
+ await this.recallPrewarmWork;
35269
+ }
35120
35270
  /** A retention change or a prune removed rows: drop the vector caches. Called only inside notes.exclusive. */
35121
35271
  onRecallRetentionChangedLocked() {
35122
35272
  this.recall.dropAllCachesLocked();
@@ -35126,8 +35276,8 @@ var BridgeAgent = class _BridgeAgent {
35126
35276
  this.recall.dropAllLocked();
35127
35277
  }
35128
35278
  /**
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
35279
+ * 7b spec §7.1, §7.2: Q1 (config half) and Q2 here, then the turn kind's
35280
+ * bound inside RecallBook.prepare (600 ms text, 250 ms voice; ruling 2026-10-09). Called beside prepareNotesForTurn, before the
35131
35281
  * turn is staged. The receipt epochs (Task 16 fix H-2) narrow the capture:
35132
35282
  * the in-cycle receipt, else the persisted staging marks; marks that exist
35133
35283
  * but lack an epoch give no recall (fail closed). Never throws.
@@ -35135,11 +35285,13 @@ var BridgeAgent = class _BridgeAgent {
35135
35285
  async prepareRecallForTurn(t) {
35136
35286
  try {
35137
35287
  if (!this.recallProviderOn() || !this.notesReadable(t.authorization)) return;
35288
+ if (this.recallPrewarmTargets.size < _BridgeAgent.RECALL_PREWARM_TARGETS_MAX) this.recallPrewarmTargets.add(t.channelId);
35138
35289
  const marks = this.recallReceipt.get(t.messageId) ?? this.recallStageEpochs.get(t.messageId);
35139
35290
  if (marks !== void 0 && (marks.purgeEpoch === void 0 || marks.modeEpoch === void 0)) return;
35140
35291
  const receipt = marks === void 0 ? void 0 : { purgeEpoch: marks.purgeEpoch, modeEpoch: marks.modeEpoch };
35141
35292
  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 } : {} });
35293
+ const timeoutMs = t.kind === "voice" ? RECALL_QUERY_TIMEOUT_VOICE_MS : RECALL_QUERY_TIMEOUT_TEXT_MS;
35294
+ await this.recall.prepare({ messageId: t.messageId, conversationId: t.channelId, query: t.text, excludeIds: exclude, timeoutMs, ...receipt ? { receipt } : {} });
35143
35295
  } catch (e) {
35144
35296
  writeServiceLog2("warn", "memory.recall_prepare_failed", {}, e);
35145
35297
  }
@@ -35301,8 +35453,11 @@ var BridgeAgent = class _BridgeAgent {
35301
35453
  }
35302
35454
  if (!this.recallProviderOn()) {
35303
35455
  this.reembedHot = false;
35456
+ this.recallPrewarmPending = false;
35457
+ this.recallPrewarmTargets.clear();
35304
35458
  return;
35305
35459
  }
35460
+ this.runRecallPrewarm();
35306
35461
  if (!this.reembedHot && n % 60 !== 0) return;
35307
35462
  try {
35308
35463
  this.reembedHot = await this.reembed.tick() > 0;
@@ -37094,7 +37249,10 @@ var BridgeAgent = class _BridgeAgent {
37094
37249
  const turnText = parsed.text;
37095
37250
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
37096
37251
  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 });
37252
+ if (!coalescible) {
37253
+ const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
37254
+ await this.prepareRecallForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
37255
+ }
37098
37256
  let fastPathRoomBlocked = false;
37099
37257
  if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
37100
37258
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
@@ -37178,7 +37336,7 @@ var BridgeAgent = class _BridgeAgent {
37178
37336
  const [firstId, ...tailIds] = win.ids;
37179
37337
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
37180
37338
  const text = win.parts.join(" ");
37181
- await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization });
37339
+ await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
37182
37340
  const merged = { ...held, text };
37183
37341
  void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
37184
37342
  let roomBlocked = false;
@@ -37924,6 +38082,7 @@ var BridgeAgent = class _BridgeAgent {
37924
38082
  retired_drained: this.provider.retiredDrainedCount(),
37925
38083
  // INV-CUSTODY-DURABLE §5.C.3: custody health.
37926
38084
  expired_drained: this.provider.expiredDrainedCount(),
38085
+ recall_cache_bytes: this.recall.cacheBytes(),
37927
38086
  custody_persist_failing: this.provider.custodyHealth().persistFailing,
37928
38087
  custody_persist_failures: this.provider.custodyHealth().persistFailures,
37929
38088
  custody_read_failing: this.provider.custodyHealth().readFailing,
@@ -38057,6 +38216,16 @@ var BridgeAgent = class _BridgeAgent {
38057
38216
  ]);
38058
38217
  if (timer) clearTimeout(timer);
38059
38218
  }
38219
+ if (this.recallPrewarmWork) {
38220
+ let timer;
38221
+ await Promise.race([
38222
+ this.recallPrewarmWork,
38223
+ new Promise((resolve) => {
38224
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
38225
+ })
38226
+ ]);
38227
+ if (timer) clearTimeout(timer);
38228
+ }
38060
38229
  this.embedder.close?.();
38061
38230
  if (this.replyWork.size) {
38062
38231
  let timer;
@@ -16,6 +16,12 @@ export interface Embedder {
16
16
  state(): EmbedderState;
17
17
  ensureStarted(): void;
18
18
  filesPresent(): boolean;
19
+ /**
20
+ * Cold-path ruling (2026-10-09): true at once when ready; while preparing, true on the transition to ready and false
21
+ * on a failure, a stop or after `ms`. False at once in any other state. Never rejects and never starts, connects or
22
+ * spawns anything itself (the caller runs ensureStarted first).
23
+ */
24
+ ready(ms: number): Promise<boolean>;
19
25
  embed(text: string, priority: 0 | 1, opts?: {
20
26
  signal?: AbortSignal;
21
27
  }): Promise<Float32Array>;
@@ -64,12 +70,19 @@ export declare class EmbedderClient implements Embedder {
64
70
  private verifyAttempt;
65
71
  private fails;
66
72
  private closed;
73
+ /** When this client last sent a job (or connected, if later): the idle-stop test (cold-path ruling 2026-10-09). */
74
+ private lastActivity;
75
+ /** ready(ms) waiters: settled on the next transition out of "preparing". */
76
+ private readonly readyWaiters;
67
77
  private readonly m;
68
78
  private readonly now;
69
79
  private readonly log;
70
80
  constructor(o: EmbedderClientOptions);
71
81
  /** Pure: the report path reads this, and reading it never starts, connects or spawns anything (plan audit LOW). */
72
82
  state(): EmbedderState;
83
+ /** Cold-path ruling (2026-10-09): see Embedder.ready. Pure like state(): never starts, connects or spawns anything. */
84
+ ready(ms: number): Promise<boolean>;
85
+ private settleReady;
73
86
  private tcp;
74
87
  filesPresent(): boolean;
75
88
  private key;
@@ -87,6 +100,13 @@ export declare class EmbedderClient implements Embedder {
87
100
  private aclToken;
88
101
  private aclOk;
89
102
  private fail;
103
+ /**
104
+ * Rule 6, cold-path ruling (2026-10-09): the served socket closed. With no job pending and this client's last job (or
105
+ * its connect, if later) at least the shared idle constant ago, it is the shared embedder's idle stop, which is not a
106
+ * failure: back to `off`, no `fails++`, no `notBefore`, no unavailable report, so the next turn's ensureStarted starts
107
+ * it at once. Anything else (a job pending, or recent use) stays a failure with its backoff.
108
+ */
109
+ private onServedClose;
90
110
  ensureStarted(): void;
91
111
  private start;
92
112
  /** The embedder's fixed-code status file (exit 3). True when it decided the state. */
@@ -1,6 +1,11 @@
1
1
  /** Spec §20 E1.2 to E1.4: newline-delimited JSON frames over the local socket; vectors as base64 little-endian float32. */
2
2
  export declare const EMBEDDER_PROTOCOL = 1;
3
3
  export declare const MAX_TEXT_BYTES = 8192;
4
+ /**
5
+ * The shared embedder stops after this long with no job from any bridge (server `idleMs` default). The client uses the
6
+ * same value to tell that idle stop from a failure (cold-path ruling 2026-10-09): ONE constant, used by both.
7
+ */
8
+ export declare const EMBEDDER_IDLE_MS: number;
4
9
  /**
5
10
  * 11b review M4: the longest sealed job line. Worst case for MAX_TEXT_BYTES of text: every byte a control character that
6
11
  * JSON escapes as \u00XX (6 bytes) = 49152, plus the frame's other fields (< 100), plus the 24-byte nonce and 16-byte
@@ -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,20 @@ 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;
65
+ /** About 1.5 ms of real opens at the measured ~40 µs per row: the longest a warm holds the event loop. */
66
+ private static readonly WARM_YIELD_ROWS;
58
67
  private readonly caches;
59
68
  private readonly entries;
69
+ /** The warm in flight per conversation: a cold turn awaits the same warm instead of starting a second one. */
60
70
  private readonly warming;
61
71
  private same;
62
72
  private nowMs;
63
73
  cacheWarm(conversationId: string): boolean;
74
+ /**
75
+ * Task 27 Step 1: the bytes the warm vector caches hold (a number, content-free; /healthz `recall_cache_bytes`).
76
+ * The backing buffer, not the view: a warm that skipped unreadable rows installs a shorter view of a full buffer.
77
+ */
78
+ cacheBytes(): number;
64
79
  /**
65
80
  * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
66
81
  * a read failure leaves the cache cold (fail closed: no recall).
@@ -77,24 +92,31 @@ export declare class RecallBook {
77
92
  * re-read capture.
78
93
  */
79
94
  warm(conversationId: string): Promise<void>;
95
+ private warmOnce;
80
96
  /**
81
97
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
82
98
  * replaces its purge and mode epochs with the ones captured when the turn
83
99
  * 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.
100
+ * refuses too.
101
+ *
102
+ * Cold-path ruling (architect, 2026-10-09; §7.1 and §7.3 errata): ONE deadline, start + `timeoutMs` (the caller
103
+ * passes its turn kind's bound: 600 ms text, 250 ms voice), covers the ready wait, the warm and the query embed
104
+ * together; no phase has a budget of its own. A cold embedder is started (ensureStarted) and waited for; a cold or
105
+ * stale cache is warmed and waited for. Past the deadline there is no recall and nothing attaches late: a warm that
106
+ * outlives it keeps running and may install (warm enforces H2 itself), but this turn gets no entry.
86
107
  */
87
108
  prepare(p: {
88
109
  messageId: string;
89
110
  conversationId: string;
90
111
  query: string;
91
112
  excludeIds: ReadonlySet<string>;
92
- timeoutMs?: number;
113
+ timeoutMs: number;
93
114
  receipt?: {
94
115
  purgeEpoch: number;
95
116
  modeEpoch: number;
96
117
  };
97
118
  }): Promise<void>;
119
+ private attach;
98
120
  /** Q3 + Q6: the entry's own conversation, and its capture still current at delivery. Synchronous (ws flush). */
99
121
  deliverable(messageId: string, conversationId: string): readonly RecallItem[] | undefined;
100
122
  /**
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.2";
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.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"