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