@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 +33 -0
- package/dist/bin.js +209 -40
- package/dist/bridge-agent.d.ts +38 -2
- package/dist/embedder.js +2 -1
- package/dist/index.js +209 -40
- package/dist/memory/embedder/client.d.ts +20 -0
- package/dist/memory/embedder/protocol.d.ts +5 -0
- package/dist/memory/recall/recall-book.d.ts +26 -4
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30964
|
-
|
|
30965
|
-
|
|
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.
|
|
31022
|
-
*
|
|
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
|
-
|
|
31029
|
-
|
|
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
|
|
31042
|
-
|
|
31043
|
-
|
|
31044
|
-
|
|
31045
|
-
|
|
31046
|
-
|
|
31047
|
-
|
|
31048
|
-
|
|
31049
|
-
|
|
31050
|
-
|
|
31051
|
-
|
|
31052
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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;
|
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -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
|
|
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 ??
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30454
|
-
|
|
30455
|
-
|
|
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.
|
|
30512
|
-
*
|
|
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
|
-
|
|
30519
|
-
|
|
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
|
|
30532
|
-
|
|
30533
|
-
|
|
30534
|
-
|
|
30535
|
-
|
|
30536
|
-
|
|
30537
|
-
|
|
30538
|
-
|
|
30539
|
-
|
|
30540
|
-
|
|
30541
|
-
|
|
30542
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
85
|
-
*
|
|
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
|
|
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.
|
|
1
|
+
export declare const BRIDGE_VERSION = "0.28.2";
|