@securecoms/agent-bridge 0.28.1 → 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,22 @@ 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
+
801
817
  ## Release notes: 0.28.1
802
818
 
803
819
  <!-- [COPY, NEW] this section. Gate: Eric sign-off before npm publish (release PR). -->
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
 
@@ -30934,9 +30980,12 @@ var RecallBook = class _RecallBook {
30934
30980
  static IDLE_MS = 30 * 6e4;
30935
30981
  static TOP_K = 5;
30936
30982
  static TEXT_MAX_CHARS = 2e3;
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;
30937
30985
  caches = /* @__PURE__ */ new Map();
30938
30986
  entries = /* @__PURE__ */ new Map();
30939
- 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();
30940
30989
  same(a, b) {
30941
30990
  return a.clearGeneration === b.clearGeneration && a.modeEpoch === b.modeEpoch && a.purgeEpoch === b.purgeEpoch && a.contentEpoch === b.contentEpoch;
30942
30991
  }
@@ -30970,15 +31019,23 @@ var RecallBook = class _RecallBook {
30970
31019
  * re-read rows (ids gone are dropped, the few new ids are opened here, under the mutex) and installed with the
30971
31020
  * re-read capture.
30972
31021
  */
30973
- async warm(conversationId) {
30974
- if (this.warming.has(conversationId)) return;
30975
- 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) {
30976
31032
  try {
30977
31033
  const snap = this.d.store.readRecallRows(conversationId);
30978
31034
  const rows = snap.rows.filter((r) => r.modelId === this.d.modelId);
30979
31035
  const opened = /* @__PURE__ */ new Map();
30980
31036
  const failed = /* @__PURE__ */ new Set();
30981
- 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));
30982
31039
  const v = await this.d.store.openRecallVec(conversationId, r);
30983
31040
  if (!v || v.length !== DIMS) {
30984
31041
  failed.add(r.recallId);
@@ -31020,46 +31077,61 @@ var RecallBook = class _RecallBook {
31020
31077
  if (unreadable > 0) (this.d.log ?? writeServiceLog)("warn", "memory.recall_open_failed", { count: unreadable });
31021
31078
  } catch (e) {
31022
31079
  (this.d.log ?? writeServiceLog)("warn", "memory.recall_warm_failed", {}, e);
31023
- } finally {
31024
- this.warming.delete(conversationId);
31025
31080
  }
31026
31081
  }
31027
31082
  /**
31028
31083
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
31029
31084
  * replaces its purge and mode epochs with the ones captured when the turn
31030
31085
  * was received (Task 16 fix H-2), so a purge or a mode change after receipt
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.
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.
31033
31093
  */
31034
31094
  async prepare(p) {
31035
31095
  const fresh = this.d.store.recallCapture(p.conversationId);
31036
31096
  const capture = p.receipt ? { ...fresh, purgeEpoch: p.receipt.purgeEpoch, modeEpoch: p.receipt.modeEpoch } : fresh;
31037
31097
  this.entries.delete(p.messageId);
31038
- const now = this.nowMs();
31039
- this.dropIdleCaches(now);
31040
- if (this.d.embedder.state().state !== "ready") {
31041
- this.d.embedder.ensureStarted();
31042
- return;
31043
- }
31044
- const cache = this.caches.get(p.conversationId);
31045
- if (!cache || !this.same(cache.capture, capture)) {
31046
- void this.warm(p.conversationId);
31047
- return;
31048
- }
31049
- cache.lastUse = now;
31098
+ this.dropIdleCaches(this.nowMs());
31099
+ const deadlineAt = Date.now() + p.timeoutMs;
31050
31100
  const ac = new AbortController();
31101
+ let late = false;
31051
31102
  let timer;
31052
- const q = await Promise.race([
31053
- this.d.embedder.embed(p.query, 0, { signal: ac.signal }).catch(() => null),
31054
- new Promise((r) => {
31055
- timer = setTimeout(() => {
31056
- ac.abort();
31057
- r(null);
31058
- }, p.timeoutMs);
31059
- })
31060
- ]);
31061
- clearTimeout(timer);
31062
- if (!q || q.length !== DIMS) return;
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) {
31063
31135
  const ranked = rankRecall(
31064
31136
  q,
31065
31137
  { vecs: cache.vecs, dims: DIMS, messageIds: cache.messageIds, recallIds: cache.recallIds },
@@ -31081,7 +31153,7 @@ var RecallBook = class _RecallBook {
31081
31153
  items.push({ id: r.recallId, text, sent_at: row.sentAt });
31082
31154
  }
31083
31155
  if (unreadable > 0) (this.d.log ?? writeServiceLog)("warn", "memory.recall_open_failed", { count: unreadable });
31084
- if (items.length === 0) return;
31156
+ if (items.length === 0 || late()) return;
31085
31157
  if (this.entries.size >= _RecallBook.MAP_MAX) this.entries.delete(this.entries.keys().next().value);
31086
31158
  this.entries.set(p.messageId, { conversationId: p.conversationId, capture, items });
31087
31159
  }
@@ -33164,6 +33236,12 @@ var BridgeAgent = class _BridgeAgent {
33164
33236
  recallPrewarmPending = false;
33165
33237
  /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
33166
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;
33167
33245
  /**
33168
33246
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
33169
33247
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -35413,14 +35491,19 @@ var BridgeAgent = class _BridgeAgent {
35413
35491
  * before or during it stops it (I1, Q1). Never throws.
35414
35492
  */
35415
35493
  runRecallPrewarm() {
35416
- if (!this.recallPrewarmPending || this.recallPrewarmWork || this.closingDown) return;
35494
+ if (!this.recallPrewarmPending && this.recallPrewarmTargets.size === 0 || this.recallPrewarmWork || this.closingDown) return;
35417
35495
  if (this.embedder.state().state !== "ready") return;
35496
+ const all = this.recallPrewarmPending;
35418
35497
  this.recallPrewarmPending = false;
35498
+ const targets = [...this.recallPrewarmTargets];
35499
+ this.recallPrewarmTargets.clear();
35419
35500
  this.recallPrewarmWork = (async () => {
35420
35501
  try {
35421
- const dms = this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c) && this.store.getChannelMeta(c)?.isDm === true);
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);
35422
35504
  for (const c of dms) {
35423
35505
  if (this.closingDown || !this.recallProviderOn()) return;
35506
+ if (this.recall.cacheWarm(c)) continue;
35424
35507
  await this.recall.warm(c);
35425
35508
  }
35426
35509
  } catch (e) {
@@ -35452,6 +35535,7 @@ var BridgeAgent = class _BridgeAgent {
35452
35535
  async prepareRecallForTurn(t) {
35453
35536
  try {
35454
35537
  if (!this.recallProviderOn() || !this.notesReadable(t.authorization)) return;
35538
+ if (this.recallPrewarmTargets.size < _BridgeAgent.RECALL_PREWARM_TARGETS_MAX) this.recallPrewarmTargets.add(t.channelId);
35455
35539
  const marks = this.recallReceipt.get(t.messageId) ?? this.recallStageEpochs.get(t.messageId);
35456
35540
  if (marks !== void 0 && (marks.purgeEpoch === void 0 || marks.modeEpoch === void 0)) return;
35457
35541
  const receipt = marks === void 0 ? void 0 : { purgeEpoch: marks.purgeEpoch, modeEpoch: marks.modeEpoch };
@@ -35620,6 +35704,7 @@ var BridgeAgent = class _BridgeAgent {
35620
35704
  if (!this.recallProviderOn()) {
35621
35705
  this.reembedHot = false;
35622
35706
  this.recallPrewarmPending = false;
35707
+ this.recallPrewarmTargets.clear();
35623
35708
  return;
35624
35709
  }
35625
35710
  this.runRecallPrewarm();
@@ -1199,6 +1199,12 @@ export declare class BridgeAgent {
1199
1199
  private recallPrewarmPending;
1200
1200
  /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
1201
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;
1202
1208
  /**
1203
1209
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
1204
1210
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
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
 
@@ -30424,9 +30470,12 @@ var RecallBook = class _RecallBook {
30424
30470
  static IDLE_MS = 30 * 6e4;
30425
30471
  static TOP_K = 5;
30426
30472
  static TEXT_MAX_CHARS = 2e3;
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;
30427
30475
  caches = /* @__PURE__ */ new Map();
30428
30476
  entries = /* @__PURE__ */ new Map();
30429
- 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();
30430
30479
  same(a, b) {
30431
30480
  return a.clearGeneration === b.clearGeneration && a.modeEpoch === b.modeEpoch && a.purgeEpoch === b.purgeEpoch && a.contentEpoch === b.contentEpoch;
30432
30481
  }
@@ -30460,15 +30509,23 @@ var RecallBook = class _RecallBook {
30460
30509
  * re-read rows (ids gone are dropped, the few new ids are opened here, under the mutex) and installed with the
30461
30510
  * re-read capture.
30462
30511
  */
30463
- async warm(conversationId) {
30464
- if (this.warming.has(conversationId)) return;
30465
- 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) {
30466
30522
  try {
30467
30523
  const snap = this.d.store.readRecallRows(conversationId);
30468
30524
  const rows = snap.rows.filter((r) => r.modelId === this.d.modelId);
30469
30525
  const opened = /* @__PURE__ */ new Map();
30470
30526
  const failed = /* @__PURE__ */ new Set();
30471
- 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));
30472
30529
  const v = await this.d.store.openRecallVec(conversationId, r);
30473
30530
  if (!v || v.length !== DIMS) {
30474
30531
  failed.add(r.recallId);
@@ -30510,46 +30567,61 @@ var RecallBook = class _RecallBook {
30510
30567
  if (unreadable > 0) (this.d.log ?? writeServiceLog2)("warn", "memory.recall_open_failed", { count: unreadable });
30511
30568
  } catch (e) {
30512
30569
  (this.d.log ?? writeServiceLog2)("warn", "memory.recall_warm_failed", {}, e);
30513
- } finally {
30514
- this.warming.delete(conversationId);
30515
30570
  }
30516
30571
  }
30517
30572
  /**
30518
30573
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
30519
30574
  * replaces its purge and mode epochs with the ones captured when the turn
30520
30575
  * was received (Task 16 fix H-2), so a purge or a mode change after receipt
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.
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.
30523
30583
  */
30524
30584
  async prepare(p) {
30525
30585
  const fresh = this.d.store.recallCapture(p.conversationId);
30526
30586
  const capture = p.receipt ? { ...fresh, purgeEpoch: p.receipt.purgeEpoch, modeEpoch: p.receipt.modeEpoch } : fresh;
30527
30587
  this.entries.delete(p.messageId);
30528
- const now = this.nowMs();
30529
- this.dropIdleCaches(now);
30530
- if (this.d.embedder.state().state !== "ready") {
30531
- this.d.embedder.ensureStarted();
30532
- return;
30533
- }
30534
- const cache = this.caches.get(p.conversationId);
30535
- if (!cache || !this.same(cache.capture, capture)) {
30536
- void this.warm(p.conversationId);
30537
- return;
30538
- }
30539
- cache.lastUse = now;
30588
+ this.dropIdleCaches(this.nowMs());
30589
+ const deadlineAt = Date.now() + p.timeoutMs;
30540
30590
  const ac = new AbortController();
30591
+ let late = false;
30541
30592
  let timer;
30542
- const q = await Promise.race([
30543
- this.d.embedder.embed(p.query, 0, { signal: ac.signal }).catch(() => null),
30544
- new Promise((r) => {
30545
- timer = setTimeout(() => {
30546
- ac.abort();
30547
- r(null);
30548
- }, p.timeoutMs);
30549
- })
30550
- ]);
30551
- clearTimeout(timer);
30552
- if (!q || q.length !== DIMS) return;
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) {
30553
30625
  const ranked = rankRecall(
30554
30626
  q,
30555
30627
  { vecs: cache.vecs, dims: DIMS, messageIds: cache.messageIds, recallIds: cache.recallIds },
@@ -30571,7 +30643,7 @@ var RecallBook = class _RecallBook {
30571
30643
  items.push({ id: r.recallId, text, sent_at: row.sentAt });
30572
30644
  }
30573
30645
  if (unreadable > 0) (this.d.log ?? writeServiceLog2)("warn", "memory.recall_open_failed", { count: unreadable });
30574
- if (items.length === 0) return;
30646
+ if (items.length === 0 || late()) return;
30575
30647
  if (this.entries.size >= _RecallBook.MAP_MAX) this.entries.delete(this.entries.keys().next().value);
30576
30648
  this.entries.set(p.messageId, { conversationId: p.conversationId, capture, items });
30577
30649
  }
@@ -32914,6 +32986,12 @@ var BridgeAgent = class _BridgeAgent {
32914
32986
  recallPrewarmPending = false;
32915
32987
  /** The DM prewarm in flight (one at a time); close() waits for it (bounded). */
32916
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;
32917
32995
  /**
32918
32996
  * Final-review item 2: conversations whose `audience` skip was already logged in this process. A room (or any
32919
32997
  * conversation the owner-DM gate refuses) refuses every turn the same way, so the line is written once per
@@ -35163,14 +35241,19 @@ var BridgeAgent = class _BridgeAgent {
35163
35241
  * before or during it stops it (I1, Q1). Never throws.
35164
35242
  */
35165
35243
  runRecallPrewarm() {
35166
- if (!this.recallPrewarmPending || this.recallPrewarmWork || this.closingDown) return;
35244
+ if (!this.recallPrewarmPending && this.recallPrewarmTargets.size === 0 || this.recallPrewarmWork || this.closingDown) return;
35167
35245
  if (this.embedder.state().state !== "ready") return;
35246
+ const all = this.recallPrewarmPending;
35168
35247
  this.recallPrewarmPending = false;
35248
+ const targets = [...this.recallPrewarmTargets];
35249
+ this.recallPrewarmTargets.clear();
35169
35250
  this.recallPrewarmWork = (async () => {
35170
35251
  try {
35171
- const dms = this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c) && this.store.getChannelMeta(c)?.isDm === true);
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);
35172
35254
  for (const c of dms) {
35173
35255
  if (this.closingDown || !this.recallProviderOn()) return;
35256
+ if (this.recall.cacheWarm(c)) continue;
35174
35257
  await this.recall.warm(c);
35175
35258
  }
35176
35259
  } catch (e) {
@@ -35202,6 +35285,7 @@ var BridgeAgent = class _BridgeAgent {
35202
35285
  async prepareRecallForTurn(t) {
35203
35286
  try {
35204
35287
  if (!this.recallProviderOn() || !this.notesReadable(t.authorization)) return;
35288
+ if (this.recallPrewarmTargets.size < _BridgeAgent.RECALL_PREWARM_TARGETS_MAX) this.recallPrewarmTargets.add(t.channelId);
35205
35289
  const marks = this.recallReceipt.get(t.messageId) ?? this.recallStageEpochs.get(t.messageId);
35206
35290
  if (marks !== void 0 && (marks.purgeEpoch === void 0 || marks.modeEpoch === void 0)) return;
35207
35291
  const receipt = marks === void 0 ? void 0 : { purgeEpoch: marks.purgeEpoch, modeEpoch: marks.modeEpoch };
@@ -35370,6 +35454,7 @@ var BridgeAgent = class _BridgeAgent {
35370
35454
  if (!this.recallProviderOn()) {
35371
35455
  this.reembedHot = false;
35372
35456
  this.recallPrewarmPending = false;
35457
+ this.recallPrewarmTargets.clear();
35373
35458
  return;
35374
35459
  }
35375
35460
  this.runRecallPrewarm();
@@ -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
@@ -62,8 +62,11 @@ export declare class RecallBook {
62
62
  private static readonly IDLE_MS;
63
63
  private static readonly TOP_K;
64
64
  private static readonly TEXT_MAX_CHARS;
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;
65
67
  private readonly caches;
66
68
  private readonly entries;
69
+ /** The warm in flight per conversation: a cold turn awaits the same warm instead of starting a second one. */
67
70
  private readonly warming;
68
71
  private same;
69
72
  private nowMs;
@@ -89,12 +92,18 @@ export declare class RecallBook {
89
92
  * re-read capture.
90
93
  */
91
94
  warm(conversationId: string): Promise<void>;
95
+ private warmOnce;
92
96
  /**
93
97
  * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
94
98
  * replaces its purge and mode epochs with the ones captured when the turn
95
99
  * was received (Task 16 fix H-2), so a purge or a mode change after receipt
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.
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.
98
107
  */
99
108
  prepare(p: {
100
109
  messageId: string;
@@ -107,6 +116,7 @@ export declare class RecallBook {
107
116
  modeEpoch: number;
108
117
  };
109
118
  }): Promise<void>;
119
+ private attach;
110
120
  /** Q3 + Q6: the entry's own conversation, and its capture still current at delivery. Synchronous (ws flush). */
111
121
  deliverable(messageId: string, conversationId: string): readonly RecallItem[] | undefined;
112
122
  /**
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.28.1";
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.1",
3
+ "version": "0.28.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"