@securecoms/agent-bridge 0.27.0 → 0.28.0

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.
Files changed (49) hide show
  1. package/README.md +41 -0
  2. package/dist/bin.d.ts +1 -1
  3. package/dist/bin.js +3396 -380
  4. package/dist/bridge-agent.d.ts +182 -1
  5. package/dist/config.d.ts +7 -0
  6. package/dist/embedder-manifest.json +52 -0
  7. package/dist/embedder-worker.js +2898 -0
  8. package/dist/embedder.js +1574 -0
  9. package/dist/index.js +3201 -306
  10. package/dist/memory/consumer-recall.d.ts +21 -0
  11. package/dist/memory/embedder/client.d.ts +103 -0
  12. package/dist/memory/embedder/download.d.ts +28 -0
  13. package/dist/memory/embedder/embedder-main.d.ts +1 -0
  14. package/dist/memory/embedder/embedder-paths.d.ts +14 -0
  15. package/dist/memory/embedder/engine.d.ts +21 -0
  16. package/dist/memory/embedder/gc.d.ts +2 -0
  17. package/dist/memory/embedder/handshake.d.ts +7 -0
  18. package/dist/memory/embedder/heartbeats.d.ts +4 -0
  19. package/dist/memory/embedder/import-bundle.d.ts +6 -0
  20. package/dist/memory/embedder/inner-source.d.ts +2 -0
  21. package/dist/memory/embedder/layout.d.ts +36 -0
  22. package/dist/memory/embedder/locks.d.ts +17 -0
  23. package/dist/memory/embedder/manifest.d.ts +30 -0
  24. package/dist/memory/embedder/no-dial.d.ts +3 -0
  25. package/dist/memory/embedder/protocol.d.ts +55 -0
  26. package/dist/memory/embedder/run-dir.d.ts +11 -0
  27. package/dist/memory/embedder/scheduler.d.ts +24 -0
  28. package/dist/memory/embedder/server.d.ts +46 -0
  29. package/dist/memory/embedder/session.d.ts +17 -0
  30. package/dist/memory/embedder/spawn.d.ts +40 -0
  31. package/dist/memory/embedder/tokenizer.d.ts +41 -0
  32. package/dist/memory/embedder/types.d.ts +32 -0
  33. package/dist/memory/embedder/windows.d.ts +46 -0
  34. package/dist/memory/embedder/worker-body.d.ts +44 -0
  35. package/dist/memory/embedder/worker.d.ts +1 -0
  36. package/dist/memory/memory-config.d.ts +13 -0
  37. package/dist/memory/note-frames.d.ts +5 -2
  38. package/dist/memory/notes-book.d.ts +21 -3
  39. package/dist/memory/provider.d.ts +2 -0
  40. package/dist/memory/recall/call-heartbeat.d.ts +35 -0
  41. package/dist/memory/recall/membership-purge.d.ts +82 -0
  42. package/dist/memory/recall/rank.d.ts +15 -0
  43. package/dist/memory/recall/recall-book.d.ts +140 -0
  44. package/dist/memory/recall/reembed.d.ts +35 -0
  45. package/dist/memory-taps.d.ts +3 -2
  46. package/dist/ort-wasm-simd-threaded.wasm +0 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/ws-server.d.ts +13 -1
  49. package/package.json +2 -1
@@ -0,0 +1,44 @@
1
+ import type { EmbedderWorkerData } from "./types.js";
2
+ /** The slice of onnxruntime-web this body uses; the real module satisfies it. */
3
+ export interface OrtLike {
4
+ env: {
5
+ wasm: {
6
+ wasmBinary?: ArrayBufferLike | Uint8Array;
7
+ numThreads?: number;
8
+ proxy?: boolean;
9
+ };
10
+ };
11
+ Tensor: new (type: "int64", data: BigInt64Array, dims: number[]) => unknown;
12
+ InferenceSession: {
13
+ create(model: Uint8Array): Promise<{
14
+ run(feeds: Record<string, unknown>): Promise<Record<string, {
15
+ data: unknown;
16
+ }>>;
17
+ }>;
18
+ };
19
+ }
20
+ export interface PortLike {
21
+ postMessage(m: unknown, transfer?: ArrayBuffer[]): void;
22
+ on(ev: "message", f: (m: {
23
+ job_id: number;
24
+ text: string;
25
+ }) => void): void;
26
+ }
27
+ type Spec = {
28
+ path: string;
29
+ sha256: string;
30
+ size: number;
31
+ };
32
+ /** What the body needs: the worker data with a lookup key per file (erratum E1: `read` maps the key to the bytes it was handed). */
33
+ export type WorkerBodyData = Omit<EmbedderWorkerData, "bytes" | "model" | "vocab" | "wasm"> & {
34
+ model: Spec;
35
+ vocab: Spec;
36
+ wasm: Spec;
37
+ };
38
+ export declare function runWorker(o: {
39
+ read: (path: string) => Uint8Array;
40
+ ort: OrtLike;
41
+ port: PortLike;
42
+ data: WorkerBodyData;
43
+ }): Promise<"ready" | "fatal">;
44
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,19 @@
1
+ /**
2
+ * Spec §11.2 (OQ-L), plan D-5: the bridge parse of `payload.memory`.
3
+ *
4
+ * The mode is not a security control. A value the bridge cannot read turns
5
+ * notes OFF (no reads, writes refused with `mode`) and deletes nothing; it
6
+ * never purges. Absent and `{}` mean `{ mode: "markdown", clear_generation: 0 }`
7
+ * (an old backend, or a row never given a memory value). `null` is neither,
8
+ * so it fails closed (D-5). `"semantic"` turns recall on as well (7b, spec §5.3);
9
+ * notes stay on in both modes.
10
+ */
11
+ import { type RecallDays } from "@agentvault/connect-mls-store";
1
12
  export type PayloadMemory = {
2
13
  notesOn: true;
3
14
  clearGeneration: number;
15
+ mode: "markdown" | "semantic";
16
+ recallDays?: RecallDays;
4
17
  unrecognized?: undefined;
5
18
  } | {
6
19
  notesOn: false;
@@ -8,13 +8,16 @@
8
8
  * is ignored.
9
9
  */
10
10
  /** Spec §8.2, §18.1: content-free refusal classes, the only thing a refusal says. */
11
- export declare const REASON_CLASSES: readonly ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store"];
11
+ export declare const REASON_CLASSES: readonly ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged"];
12
12
  export type ReasonClass = (typeof REASON_CLASSES)[number];
13
13
  export interface MemoryNoteFrame {
14
14
  request_id: string;
15
15
  message_id: string;
16
16
  op: "add" | "replace" | "forget";
17
- id?: number;
17
+ /** A note id, or (forget only, spec §9.1) a recall id written `r<n>` on the wire. */
18
+ id?: number | {
19
+ recall: number;
20
+ };
18
21
  text?: string;
19
22
  }
20
23
  export interface MemoryNoteResultFrame {
@@ -22,7 +22,7 @@
22
22
  * until overwritten, and still opens under the same device key. `secure_delete`
23
23
  * is not enabled (database-wide cost). Recorded for the release notes.
24
24
  */
25
- import { type NoteCommit, type NoteCommitResult, type NotesSnapshot } from "@agentvault/connect-mls-store";
25
+ import { type NoteCommit, type NoteCommitResult, type NotesSnapshot, type RuntimeInboxRewrite } from "@agentvault/connect-mls-store";
26
26
  import { type Provenance } from "./note-provenance.js";
27
27
  import { type ServiceLog } from "../service-log.js";
28
28
  export interface NotesStore {
@@ -37,6 +37,7 @@ export interface NotesStore {
37
37
  text: string;
38
38
  }>>;
39
39
  purgeMemory(conversationId: string): void;
40
+ conversationPurgeEpoch(conversationId: string): number;
40
41
  }
41
42
  /** FIFO async mutex. A rejected holder releases the lock; it never wedges the queue. */
42
43
  export declare class AsyncMutex {
@@ -74,6 +75,11 @@ export interface ImportAuthorization {
74
75
  readonly memory_write: true;
75
76
  }
76
77
  export type ClearOutcome = "applied" | "equal" | "lower" | "out_of_range";
78
+ /**
79
+ * 7b Task 18 (spec §9.1 W9, audit r2 M-B): a replace and a forget are destructive, so each carries the
80
+ * prepared runtime_inbox rewrite that spends this message's one destructive operation. It is REQUIRED by
81
+ * type: the count commits in the same transaction as the change, or neither does.
82
+ */
77
83
  export type NoteMutation = {
78
84
  op: "add";
79
85
  text: StoredNoteText;
@@ -81,10 +87,17 @@ export type NoteMutation = {
81
87
  op: "replace";
82
88
  id: number;
83
89
  text: StoredNoteText;
90
+ rewrite: RuntimeInboxRewrite;
84
91
  } | {
85
92
  op: "forget";
86
93
  id: number;
94
+ rewrite: RuntimeInboxRewrite;
87
95
  };
96
+ /**
97
+ * `budget`: this message already spent its destructive operation (W9). `purged`: the
98
+ * conversation was purged after the write was queued (Task 14 M-A; Task 18 carry 5, distinct
99
+ * from the owner's clear).
100
+ */
88
101
  export type NoteWriteResult = {
89
102
  ok: true;
90
103
  id: number;
@@ -93,7 +106,7 @@ export type NoteWriteResult = {
93
106
  deduped?: true;
94
107
  } | {
95
108
  ok: false;
96
- reason: "cap" | "cleared" | "store" | "invalid" | PrecommitVeto;
109
+ reason: "cap" | "cleared" | "purged" | "budget" | "store" | "invalid" | PrecommitVeto;
97
110
  };
98
111
  /**
99
112
  * A caller's last check, run inside the mutex after the seal and immediately
@@ -104,6 +117,7 @@ export type PrecommitVeto = "message" | "mode";
104
117
  export declare class NotesBook {
105
118
  private readonly store;
106
119
  private readonly log;
120
+ private readonly onClearedLocked;
107
121
  private readonly mutex;
108
122
  private readonly cache;
109
123
  /**
@@ -119,7 +133,9 @@ export declare class NotesBook {
119
133
  private readonly imported;
120
134
  /** In-memory mirror of the stored applied generation, changed only inside clear() (an apply or a resync). */
121
135
  private generation;
122
- constructor(store: NotesStore, log?: ServiceLog);
136
+ constructor(store: NotesStore, log?: ServiceLog, onClearedLocked?: () => void);
137
+ /** The memory mutex for recall work (spec §4 M2: the NotesBook mutex is the per-identity memory mutex). Leaf lock. */
138
+ exclusive<T>(fn: () => Promise<T>): Promise<T>;
123
139
  appliedGeneration(): number;
124
140
  /** Synchronous, for ws-server flush. A missing entry or a stale generation is a miss (N-M1). */
125
141
  deliverable(channelId: string): {
@@ -140,6 +156,8 @@ export declare class NotesBook {
140
156
  * (review m6c) without re-reading appliedGeneration().
141
157
  */
142
158
  clear(generation: number): Promise<ClearOutcome>;
159
+ /** The clear is committed: a hook failure must not reject it (the caller would retry a done clear). Call only under the mutex. */
160
+ private callClearedHook;
143
161
  /** §6.4 (OQ-K): the channel purge, with its cache entry emptied inside the mutex. */
144
162
  purgeChannel(channelId: string): Promise<void>;
145
163
  /**
@@ -4,6 +4,7 @@ export interface MemoryProvider {
4
4
  record(input: MemoryRecordInput): Promise<{
5
5
  seq: number;
6
6
  deduped: boolean;
7
+ dropped?: true;
7
8
  }>;
8
9
  context(conversationId: string, limit?: number): Promise<MemoryContextResult>;
9
10
  /** Optional adapter counters surfaced on the daemon healthcheck. */
@@ -16,6 +17,7 @@ export declare class SecureComsLocalMemory implements MemoryProvider {
16
17
  record(input: MemoryRecordInput): Promise<{
17
18
  seq: number;
18
19
  deduped: boolean;
20
+ dropped?: true;
19
21
  }>;
20
22
  context(conversationId: string, limit?: number): Promise<MemoryContextResult>;
21
23
  }
@@ -0,0 +1,35 @@
1
+ import { type ServiceLog } from "../../service-log.js";
2
+ import { HEARTBEAT_STALE_MS } from "../embedder/heartbeats.js";
3
+ /** Ruling P-1 (plan, controller rulings): a `voiceMode` turn ingested within the last 120 s means a live call. */
4
+ export declare const VOICE_LIVE_WINDOW_MS = 120000;
5
+ /** Spec §8: the heartbeat's mtime is refreshed every 10 s while live. */
6
+ export declare const HEARTBEAT_REFRESH_MS = 10000;
7
+ /** Spec §8, §20 E1.4: background jobs are held while any heartbeat is newer than 30 s (fix M1: and not far in the future). */
8
+ export { HEARTBEAT_STALE_MS };
9
+ export declare class CallHeartbeat {
10
+ /** null: disabled (Task 19 fix M2, a construction without machine paths): nothing is written or read. */
11
+ private readonly dir;
12
+ private readonly identityId;
13
+ private readonly liveWindowMs;
14
+ private readonly now;
15
+ private readonly log;
16
+ private lastVoiceAt;
17
+ private wrote;
18
+ private refusedLogged;
19
+ constructor(
20
+ /** null: disabled (Task 19 fix M2, a construction without machine paths): nothing is written or read. */
21
+ dir: string | null, identityId: string, liveWindowMs: number, now?: () => number, log?: ServiceLog);
22
+ private live;
23
+ /** A `voiceMode` turn was ingested. Going live writes the heartbeat at once (no 10 s lag before other bridges pause). */
24
+ noteVoiceActivity(): void;
25
+ /** Every 10 s: touch the file while live (ruling P-1), remove it when not. Never throws. */
26
+ refresh(): void;
27
+ /** Bridge stop: the call (as far as this bridge knows) ends with it. */
28
+ stop(): void;
29
+ /** True when any identity's heartbeat (a regular file) is fresh (heartbeats.ts: age in [-60 s, staleMs]). Never throws. */
30
+ anyLive(staleMs?: number): boolean;
31
+ /** The checked directory, or null when disabled or refused. */
32
+ private dirOk;
33
+ private touch;
34
+ private remove;
35
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Spec §5.4 (H4, D-6) with audit r2 H-A and audit r1 (plan) C2, H6, M6, plus
3
+ * the Task 13 dr-evil fix round. The only production caller of purgeMemory.
4
+ * Purges turns, facts, notes and recall of a conversation the agent has left,
5
+ * on evidence only:
6
+ *
7
+ * - The conversation's OWN recorded enclave was listed successfully and
8
+ * NON-EMPTY in this sweep, and that listing does not contain it. Nothing
9
+ * else is evidence: no recorded enclave, a failed listing, an empty listing
10
+ * (a membership or RLS false-zero answers 200 []) and an enclave not
11
+ * enumerated this sweep all leave the record as it is.
12
+ * - The conversation has been listed before (the store's persisted is_dm is
13
+ * set only from a listing). A channel no listing has ever shown (a hidden
14
+ * FileSafe vault channel) is never evidence, across restarts too.
15
+ * - A DM (store is_dm === true) additionally needs a per-channel 404 in the
16
+ * same sweep. A DM is never purged on the listing alone, and a 404 alone is
17
+ * never enough either (fix round CRITICAL: an empty listing plus a 404 on
18
+ * every probe must not purge the owner DM).
19
+ * - No inbound message arrived for it since the previous absence (LOW 3 veto).
20
+ * - The absences are consecutive: a record whose newest absence is older than
21
+ * two sweep intervals restarts (MEDIUM 1).
22
+ * - Two absences at least an hour apart; more than one NEW absence in one
23
+ * sweep is held.
24
+ *
25
+ * A malicious backend can still cause a purge by hiding one channel twice:
26
+ * availability only (§2).
27
+ *
28
+ * Limitation (stated, not fixed): removal from a whole enclave makes its
29
+ * listing 404, which is a failed listing, so no conversation in it is ever
30
+ * purged.
31
+ */
32
+ import { type ServiceLog } from "../../service-log.js";
33
+ export declare const PURGE_MIN_GAP_MS: number;
34
+ export declare const PURGE_SWEEP_INTERVAL_MS: number;
35
+ /** Fix round MEDIUM 1: a record whose newest absence is older than this restarts. */
36
+ export declare const PURGE_ABSENCE_EXPIRY_MS: number;
37
+ export type ProbeResult = "member" | "gone" | "error";
38
+ export type AbsenceRecord = {
39
+ firstAbsentAt: number;
40
+ refreshes: number;
41
+ lastAbsentAt: number;
42
+ };
43
+ /** Audit r1 C2: only a 404 is "gone"; 2xx is "member"; 401, 403, any other status, and a throw are "error". */
44
+ export declare function probeResultFor(status: number | "threw"): ProbeResult;
45
+ export interface MembershipPurgeDeps {
46
+ conversations(): string[];
47
+ enclaveOf(conversationId: string): string | undefined;
48
+ /** The store's persisted is_dm: true, false, or undefined when no listing has ever shown the channel. */
49
+ isDm(conversationId: string): boolean | undefined;
50
+ enclaves(): Promise<Iterable<string>>;
51
+ listChannels(enclaveId: string): Promise<ReadonlyArray<{
52
+ channel_id: string;
53
+ }>>;
54
+ probeChannel(conversationId: string): Promise<ProbeResult>;
55
+ absenceOf(conversationId: string): AbsenceRecord | undefined;
56
+ markAbsent(conversationId: string, now: number): AbsenceRecord;
57
+ clearAbsence(conversationId: string): void;
58
+ purge(conversationId: string): Promise<void>;
59
+ now(): number;
60
+ /** LOW 3: when an inbound message for the conversation was last received, if known. */
61
+ lastInboundAt?(conversationId: string): number | undefined;
62
+ /** MEDIUM 3: true once the host is closing; the sweep stops before its next step. */
63
+ stopping?(): boolean;
64
+ log?: ServiceLog;
65
+ }
66
+ export type SweepOutcome = {
67
+ purged: string[];
68
+ held: number;
69
+ absent: number;
70
+ };
71
+ export declare class MembershipPurgeSweeper {
72
+ private readonly d;
73
+ private running;
74
+ private held;
75
+ private prevSweepAt;
76
+ constructor(d: MembershipPurgeDeps);
77
+ /** Audit r2 LOW: the held-departure count of the newest sweep, for machine_report.memory_purge_held. */
78
+ lastHeld(): number;
79
+ /** Task 13 carry LOW 1: the start time of the newest COMPLETED sweep (a sweep stopped while closing is not one). */
80
+ lastSweepAt(): number | undefined;
81
+ sweep(): Promise<SweepOutcome>;
82
+ }
@@ -0,0 +1,15 @@
1
+ /** Spec §7.3: brute-force cosine (dot product of normalised vectors), on the main thread (≤ 5,000 × 384 multiply-adds). */
2
+ export declare function rankRecall(query: Float32Array, cache: {
3
+ vecs: Float32Array;
4
+ dims: number;
5
+ messageIds: readonly string[];
6
+ recallIds: readonly number[];
7
+ }, o: {
8
+ exclude: ReadonlySet<string>;
9
+ threshold: number;
10
+ k: number;
11
+ }): Array<{
12
+ recallId: number;
13
+ messageId: string;
14
+ score: number;
15
+ }>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Semantic memory 7b: the bridge's recall index and read path (spec §4, §7).
3
+ * Runs every mutation inside the NotesBook mutex (`exclusive`), which is the
4
+ * per-identity memory mutex. Embedding and sealing happen OUTSIDE it (M2).
5
+ *
6
+ * I8: `index` is reached only from BridgeAgent.tapRecall (the ingest tap and
7
+ * the restore re-run). recall-single-writer.arch.test.ts pins that.
8
+ */
9
+ import type { RecallCapture, RecallRow, RuntimeInboxRewrite, SqliteMlsStateStore } from "@agentvault/connect-mls-store";
10
+ import { type ServiceLog } from "../../service-log.js";
11
+ import type { Embedder } from "../embedder/client.js";
12
+ export type IndexSkip = "mode" | "audience" | "voice" | "length" | "secret" | "stamp_missing" | "cleared" | "epoch" | "purged" | "duplicate" | "store";
13
+ export interface RecallTapCapture {
14
+ readonly generation: number | undefined;
15
+ readonly capture: RecallCapture;
16
+ }
17
+ export interface RecallItem {
18
+ readonly id: number;
19
+ readonly text: string;
20
+ readonly sent_at: number;
21
+ }
22
+ export type RecallStore = Pick<SqliteMlsStateStore, "recallState" | "recallCapture" | "sealRecallText" | "sealRecallVec" | "insertRecall" | "readRecallRows" | "readRecallRow" | "openRecallText" | "openRecallVec" | "updateRecallVec" | "listPendingRecall" | "deleteRecall" | "recentMemoryMessageIds">;
23
+ export declare class RecallBook {
24
+ private readonly d;
25
+ constructor(d: {
26
+ store: RecallStore;
27
+ embedder: Embedder;
28
+ exclusive: <T>(fn: () => Promise<T>) => Promise<T>;
29
+ modelId: string;
30
+ threshold: number;
31
+ now?: () => number;
32
+ log?: ServiceLog;
33
+ });
34
+ /**
35
+ * Spec §4 order: (1) seal and embed outside the mutex; (2) take the mutex;
36
+ * (3) one transaction (insertRecall) that re-checks I6, I7 and M-A, then I9.
37
+ * A seal failure inserts nothing (fail closed, §5.2). An embedder failure
38
+ * inserts a pending row (sealed_vec NULL) for the re-embed (§8).
39
+ */
40
+ index(i: {
41
+ conversationId: string;
42
+ messageId: string;
43
+ text: string;
44
+ sentAt: number;
45
+ capture: RecallCapture;
46
+ }): Promise<{
47
+ ok: true;
48
+ recallId: number;
49
+ } | {
50
+ ok: false;
51
+ reason: IndexSkip;
52
+ }>;
53
+ private static readonly MAP_MAX;
54
+ private static readonly IDLE_MS;
55
+ private static readonly TOP_K;
56
+ private static readonly TEXT_MAX_CHARS;
57
+ private static readonly QUERY_TIMEOUT_MS;
58
+ private readonly caches;
59
+ private readonly entries;
60
+ private readonly warming;
61
+ private same;
62
+ private nowMs;
63
+ cacheWarm(conversationId: string): boolean;
64
+ /**
65
+ * §7.3 (H2): snapshot first, open outside the mutex, install under it. Never on a turn's path. Never rejects:
66
+ * a read failure leaves the cache cold (fail closed: no recall).
67
+ *
68
+ * Final-review item 1 (HIGH): prepare() starts this warm on a cold cache and the same turn's tap inserts while
69
+ * the vectors are still opening. At the row cap that insert count-prunes and moves the content epoch, so the old
70
+ * "install only if all four epochs are unchanged" rule never installed, and the next turn repeated the race
71
+ * forever; below the cap the new row was missing from the installed cache. So at install, under the mutex, the
72
+ * rows and the capture are re-read synchronously:
73
+ * - the clear generation, the mode epoch and the purge epoch must equal the snapshot's, else NOTHING installs
74
+ * (a clear, a mode change or a purge during the warm always wins);
75
+ * - the content epoch may differ (an insert, a prune, a forget or a re-embed): the cache is reconciled to the
76
+ * re-read rows (ids gone are dropped, the few new ids are opened here, under the mutex) and installed with the
77
+ * re-read capture.
78
+ */
79
+ warm(conversationId: string): Promise<void>;
80
+ /**
81
+ * §7.1: compute at ingest. Snapshot the capture FIRST (H2); `receipt`
82
+ * replaces its purge and mode epochs with the ones captured when the turn
83
+ * was received (Task 16 fix H-2), so a purge or a mode change after receipt
84
+ * refuses too. 150 ms bound on the query embed; a cold cache starts a warm
85
+ * and gives nothing.
86
+ */
87
+ prepare(p: {
88
+ messageId: string;
89
+ conversationId: string;
90
+ query: string;
91
+ excludeIds: ReadonlySet<string>;
92
+ timeoutMs?: number;
93
+ receipt?: {
94
+ purgeEpoch: number;
95
+ modeEpoch: number;
96
+ };
97
+ }): Promise<void>;
98
+ /** Q3 + Q6: the entry's own conversation, and its capture still current at delivery. Synchronous (ws flush). */
99
+ deliverable(messageId: string, conversationId: string): readonly RecallItem[] | undefined;
100
+ /**
101
+ * Spec §9.1 (H5, W9): delete one recall row and spend this message's destructive operation, in ONE store
102
+ * transaction (`deleteRecall` with the prepared rewrite). The caller has already checked that the id was
103
+ * shown with this delivery. `precommit` is the caller's last check (W1, W2), run inside the mutex with no
104
+ * await before the synchronous delete; a non-null veto deletes nothing.
105
+ *
106
+ * A forget is a delete, never an insert: I8 (single writer of insertRecall) is untouched.
107
+ * The delete moves the content epoch (audit r1 H5). The vector cache is dropped. A pending map entry that
108
+ * matched the capture just before the delete keeps its other items and adopts the new capture (the delete
109
+ * is the only change between the two reads, both inside the mutex with no await between them).
110
+ */
111
+ forget(conversationId: string, recallId: number, rewrite: RuntimeInboxRewrite, precommit?: () => "message" | "mode" | null): Promise<"ok" | "unknown_id" | "budget" | "message" | "mode">;
112
+ /**
113
+ * Spec §8 (Task 19): a pending row just took its vector (`updateRecallVec`, which moves no epoch). A warm cache
114
+ * whose capture is still current gains the row, so an active conversation's cache (kept warm by use) does not
115
+ * hide re-embedded rows until it idles out. A stale cache is dropped. Called inside `exclusive`.
116
+ */
117
+ onReembeddedLocked(conversationId: string, row: RecallRow, vec: Float32Array): void;
118
+ /** Spec §7.1: removed at ack, nack or retire. */
119
+ drop(messageId: string): void;
120
+ /** §5.4: one conversation's cache and its pending map entries. Called inside `exclusive`. */
121
+ dropConversationLocked(conversationId: string): void;
122
+ /** A retention change or a prune can remove rows: drop every vector cache. Called inside `exclusive`. */
123
+ dropAllCachesLocked(): void;
124
+ /** A clear or markdown mode: drop every cache and every map entry. Called inside `exclusive`. */
125
+ dropAllLocked(): void;
126
+ /** §7.3: a cache idle for 30 minutes is dropped. */
127
+ dropIdleCaches(now: number): void;
128
+ /**
129
+ * Task 17 fix round 1 (HIGH): at the row cap every insert count-prunes, so without this every pending map
130
+ * entry of the conversation went stale at the next insert and recall stopped. An entry is rebased only when
131
+ * its capture equals the exact pre-insert capture (clear generation, mode and purge epochs equal; the content
132
+ * epoch moved by this one insert alone) and none of its items was pruned. Anything else stays stale.
133
+ */
134
+ private rebaseEntriesLocked;
135
+ /** Spec §7.3 and audit r1 H7: an insert APPENDS its vector to a warm, current cache, inside the mutex. */
136
+ protected onInsertedLocked(conversationId: string, row: RecallRow, vec: Float32Array | null, r: {
137
+ pruned: readonly number[];
138
+ capture: RecallCapture;
139
+ }): void;
140
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Spec §8: lazy re-embed. Rows whose model_id is NULL (pending) or another model's are embedded again by background
3
+ * jobs: open the text, a priority-1 embedder job, seal under the new vector AAD, then inside the memory mutex a
4
+ * compare-and-set on the old model_id. One row per job, at most 16 rows per tick (the bridge ticks once a second;
5
+ * the shared embedder enforces 16 per second per machine, §20 E1.4). Paused while any call heartbeat of this OS user
6
+ * is fresh (H1, B-20).
7
+ *
8
+ * The capture is read BEFORE the row is re-read, opened and embedded, and the write re-checks it inside its
9
+ * transaction (`updateRecallVec`), bound to the recall id read (Task 18 carry M-4). A clear, a mode change or a purge
10
+ * that lands during the embed refuses the write; a row forgotten, pruned or purged takes no vector.
11
+ *
12
+ * Never an insert: the single-writer rule (I8) is untouched. Embedding and sealing happen outside the mutex (M2).
13
+ */
14
+ import type { RecallRow, SqliteMlsStateStore } from "@agentvault/connect-mls-store";
15
+ import type { Embedder } from "../embedder/client.js";
16
+ export type ReembedStore = Pick<SqliteMlsStateStore, "recallCapture" | "listPendingRecall" | "readRecallRow" | "openRecallText" | "sealRecallVec" | "updateRecallVec">;
17
+ export declare class ReembedQueue {
18
+ private readonly d;
19
+ constructor(d: {
20
+ store: ReembedStore;
21
+ embedder: Embedder;
22
+ exclusive: <T>(fn: () => Promise<T>) => Promise<T>;
23
+ modelId: string;
24
+ paused: () => boolean;
25
+ now?: () => number;
26
+ /** Inside the mutex, after a successful write: the bridge appends the vector to a warm, current cache. */
27
+ onEmbeddedLocked?: (conversationId: string, row: RecallRow, vec: Float32Array) => void;
28
+ embedTimeoutMs?: number;
29
+ });
30
+ private running;
31
+ private readonly parked;
32
+ /** Rows embedded and written by this call (0 when paused, not ready, already running or nothing pending). */
33
+ tick(): Promise<number>;
34
+ private tickOnce;
35
+ }
@@ -3,6 +3,7 @@ interface MemorySink {
3
3
  record(input: MemoryRecordInput): Promise<{
4
4
  seq: number;
5
5
  deduped: boolean;
6
+ dropped?: true;
6
7
  }>;
7
8
  }
8
9
  /** Record an inbound (peer) text turn the agent actually consumed. Best-effort:
@@ -11,8 +12,8 @@ interface MemorySink {
11
12
  * `modality` defaults to "text"; Task 4 (voice-mode carrier) passes "voice"
12
13
  * for a turn whose InboxEntry had the server-set `voiceMode` flag, so memory
13
14
  * continuity reflects how the turn actually arrived. */
14
- export declare function recordInboundMemory(sink: MemorySink, channelId: string, senderId: string, text: string, messageId: string, modality?: "text" | "voice"): Promise<void>;
15
+ export declare function recordInboundMemory(sink: MemorySink, channelId: string, senderId: string, text: string, messageId: string, modality?: "text" | "voice", expectPurgeEpoch?: number): Promise<void>;
15
16
  /** Record the agent's own outbound reply. Best-effort. `modality` is "voice"
16
17
  * for a line the voice fast path spoke (H1), else "text". */
17
- export declare function recordOutboundMemory(sink: MemorySink, channelId: string, text: string, messageId: string, modality?: "text" | "voice"): Promise<void>;
18
+ export declare function recordOutboundMemory(sink: MemorySink, channelId: string, text: string, messageId: string, modality?: "text" | "voice", expectPurgeEpoch?: number): Promise<void>;
18
19
  export {};
Binary file
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.27.0";
1
+ export declare const BRIDGE_VERSION = "0.28.0";
@@ -36,6 +36,18 @@ export declare const INFLIGHT_STALE_AFTER_MS: number;
36
36
  export declare const HELLO_WAIT_MS = 2000;
37
37
  /** Spec §8.5: the hello-reply feature that tells the runtime it may offer memory_note on this socket. */
38
38
  export declare const MEMORY_NOTES_FEATURE = "memory_notes";
39
- export declare function attachAgentWebSocket(server: HttpServer, agent: BridgeAgent, opts: AgentWebSocketOptions): {
39
+ /** Semantic memory 7b (spec §7.2 Q4): the hello feature a runtime declares to receive `memory.recall`; the bridge echoes it. */
40
+ export declare const MEMORY_RECALL_FEATURE = "memory_recall";
41
+ /**
42
+ * Every BridgeAgent member this module touches, directly or through the
43
+ * consumer helpers. The handlers take `WsAgent`, so tsc rejects a call to a
44
+ * member missing from this list. The ws-server test fakes are passed `as
45
+ * never` from files tsc does not check, so the fake-coverage tests assert each
46
+ * fake against this list at run time (7b gate fix: a member added here without
47
+ * a fake made three tests throw "is not a function").
48
+ */
49
+ export declare const WS_AGENT_MEMBERS: readonly ["ackRuntimeMessages", "action", "admitRuntimeDelivery", "applyMemoryNote", "clearHarnessReport", "clearPerToolConsumer", "isRuntimeMessageRetired", "noteNotesConsumer", "notePerToolConsumer", "noteRecallConsumer", "noteSessionClose", "noteSessionEvicted", "noteSessionOpen", "notesAppliedGeneration", "notesForDelivery", "recallForDelivery", "recordHarnessReport", "recordRuntimeReply", "reportStatusGated", "requeueFailedRuntimeDeliveries", "requeueFront", "runtimeReplyReceipt", "send", "sendA2A", "subscribe", "takeDeliverable", "wakeListeners"];
50
+ export type WsAgent = Pick<BridgeAgent, (typeof WS_AGENT_MEMBERS)[number]>;
51
+ export declare function attachAgentWebSocket(server: HttpServer, agent: WsAgent, opts: AgentWebSocketOptions): {
40
52
  close(): void;
41
53
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"
@@ -58,6 +58,7 @@
58
58
  "@types/node": "^22.0.0",
59
59
  "@types/ws": "^8.18.1",
60
60
  "esbuild": "0.24.0",
61
+ "onnxruntime-web": "1.29.0",
61
62
  "typescript": "^5.4.0",
62
63
  "vitest": "^3.2.6"
63
64
  }