@xdarkicex/openclaw-memory-libravdb 1.4.1 → 1.4.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.
@@ -75,3 +75,14 @@ Before opening a PR:
75
75
  - any new gating signal must come with calibration or invariant coverage
76
76
  - any retrieval math change must be reflected in [mathematics-v2.md](./mathematics-v2.md)
77
77
  - any gating change must be reflected in [gating.md](./gating.md)
78
+
79
+ ## Release Versioning
80
+
81
+ `package.json` is the source of truth for the release version.
82
+
83
+ The release automation syncs `openclaw.plugin.json` from `package.json` during the
84
+ auto-bump/tag flow, and the publish workflow refuses to publish if the Git tag,
85
+ `package.json`, and `openclaw.plugin.json` versions do not all match.
86
+
87
+ The daemon release workflow enforces the same alignment before generating the
88
+ Homebrew formula, so package, manifest, tag, and formula versioning stay in lockstep.
package/docs/gating.md CHANGED
@@ -76,8 +76,15 @@ The repetition term is a product, not a sum:
76
76
  \[ R(t) = F(t) \cdot (1 - S(t)) \]
77
77
 
78
78
  with:
79
- \[ F(t) = \min\left(\frac{\mathrm{hitsAbove}(\mathrm{turns:userId}, 0.80, k=10)}{5}, 1\right) \]
80
- \[ S(t) = \min\left(\frac{\mathrm{hitsAbove}(\mathrm{user:userId}, 0.85, k=5)}{3}, 1\right) \]
79
+ \[ F(t) = \min\left(\frac{\mathrm{hitsAbove}(\mathrm{turns:u}, 0.80, k=10)}{5}, 1\right) \]
80
+ \[ S(t) = \min\left(\frac{\mathrm{hitsAbove}(\mathrm{user:u}, 0.85, k=5)}{3}, 1\right) \]
81
+
82
+ where $u$ is the resolved durable namespace used by the host boundary. The
83
+ resolver chooses $u$ in this order: explicit `userId`, then the
84
+ session-key-derived namespace, then `agentId`, and finally the resolver
85
+ fallback/default when no host identity is available. When the host does not
86
+ provide a `userId`, the gate still measures repetition and saturation against a
87
+ stable durable scope.
81
88
 
82
89
  Why a product? High input frequency should help only if durable memory is not already saturated. High saturation must veto the repetition term regardless of frequency. The veto property is structural: $S(t) = 1 \Rightarrow R(t) = 0$.
83
90
 
@@ -61,7 +61,7 @@ $$
61
61
  S(d)=
62
62
  \begin{cases}
63
63
  1.0 & \text{if } d \text{ is from the active session} \\
64
- 0.6 & \text{if } d \text{ is from durable user memory} \\
64
+ 0.6 & \text{if } d \text{ is from durable namespace memory} \\
65
65
  0.3 & \text{if } d \text{ is from global memory}
66
66
  \end{cases}
67
67
  $$
@@ -182,7 +182,7 @@ product $\lambda_s \Delta t_d$ is dimensionless, as required by the exponential.
182
182
  The current implementation uses different constants by scope:
183
183
 
184
184
  - active session: $\lambda_s = 0.0001$
185
- - durable user memory: $\lambda_s = 0.00001$
185
+ - durable namespace memory: $\lambda_s = 0.00001$
186
186
  - global memory: $\lambda_s = 0.000002$
187
187
 
188
188
  The implied half-lives make the decay constants auditable at a glance:
@@ -200,9 +200,15 @@ $$
200
200
  If those half-lives feel wrong for a given deployment, adjust $\lambda_s$ via
201
201
  config — do not change the decay formula itself.
202
202
 
203
- This makes session context fade fastest, user memory fade more slowly, and
203
+ This makes session context fade fastest, durable namespace memory fade more slowly, and
204
204
  global memory remain the most stable.
205
205
 
206
+ When the host supplies an explicit `userId`, the durable namespace matches that
207
+ `userId`. When the host does not provide a `userId`, the plugin derives a stable
208
+ durable namespace from the session key, or falls back to `session:${sessionId}`
209
+ when both `userId` and `sessionKey` are absent, so the retrieval math and scope
210
+ weighting stay unchanged even when the host does not expose a separate user principal.
211
+
206
212
  **Note on symbol disambiguation.** The symbol $\lambda_s$ here denotes the
207
213
  scope-specific recency decay constant with units $\mathrm{s}^{-1}$. Section 7.3
208
214
  uses $\lambda_r$ for a separate recency constant in the planned authority weight.
@@ -2,7 +2,7 @@
2
2
  "id": "libravdb-memory",
3
3
  "name": "LibraVDB Memory",
4
4
  "description": "Persistent vector memory with three-tier hybrid scoring",
5
- "version": "1.3.11",
5
+ "version": "1.4.2",
6
6
  "kind": ["memory", "context-engine"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdarkicex/openclaw-memory-libravdb",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
3
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
4
+ import { resolveDurableNamespace } from "./durable-namespace.js";
4
5
  import type { PluginRuntime } from "./plugin-runtime.js";
5
6
  import type { LoggerLike, PluginConfig } from "./types.js";
6
7
 
@@ -26,6 +27,7 @@ type ExportResult = {
26
27
 
27
28
  type CliOptionBag = {
28
29
  userId?: string;
30
+ sessionKey?: string;
29
31
  sessionId?: string;
30
32
  limit?: string | number;
31
33
  yes?: boolean;
@@ -70,12 +72,13 @@ export function registerMemoryCli(
70
72
  .action(() => void runStatus(runtime, cfg, logger));
71
73
 
72
74
  const flush = ensureCommand(root, "flush")
73
- .description("Wipe a user memory namespace after confirmation");
75
+ .description("Wipe a durable memory namespace after confirmation");
74
76
  if (flush.requiredOption) {
75
77
  flush.requiredOption("--user-id <userId>", "User id whose durable memory should be deleted");
76
78
  } else {
77
79
  flush.option("--user-id <userId>", "User id whose durable memory should be deleted");
78
80
  }
81
+ flush.option("--session-key <sessionKey>", "Session key whose derived durable namespace should be deleted");
79
82
  flush
80
83
  .option("--yes", "Skip the confirmation prompt")
81
84
  .action((opts) => void runFlush(runtime, opts, logger));
@@ -83,6 +86,7 @@ export function registerMemoryCli(
83
86
  const exportCmd = ensureCommand(root, "export")
84
87
  .description("Stream stored memories as newline-delimited JSON");
85
88
  exportCmd.option("--user-id <userId>", "Restrict export to a single user namespace");
89
+ exportCmd.option("--session-key <sessionKey>", "Restrict export to a derived session-key namespace");
86
90
  exportCmd.action((opts) => void runExport(runtime, opts, logger));
87
91
 
88
92
  const journal = ensureCommand(root, "journal")
@@ -147,15 +151,15 @@ async function runStatus(runtime: PluginRuntime, cfg: PluginConfig, logger: Logg
147
151
  }
148
152
 
149
153
  async function runFlush(runtime: PluginRuntime, opts: CliOptionBag | undefined, logger: LoggerLike): Promise<void> {
150
- const userId = opts?.userId?.trim();
151
- if (!userId) {
152
- logger.error("LibraVDB flush requires --user-id <userId>.");
154
+ const namespace = resolveCliNamespace(opts);
155
+ if (!namespace) {
156
+ logger.error("LibraVDB flush requires --user-id <userId> or --session-key <sessionKey>.");
153
157
  process.exitCode = 1;
154
158
  return;
155
159
  }
156
160
 
157
161
  if (!opts?.yes) {
158
- const confirmed = await confirm(`Delete durable memory collection user:${userId}? [y/N] `);
162
+ const confirmed = await confirm(`Delete durable memory namespace ${namespace}? [y/N] `);
159
163
  if (!confirmed) {
160
164
  console.log("Aborted.");
161
165
  return;
@@ -164,8 +168,8 @@ async function runFlush(runtime: PluginRuntime, opts: CliOptionBag | undefined,
164
168
 
165
169
  try {
166
170
  const rpc = await runtime.getRpc();
167
- await rpc.call("flush_namespace", { userId });
168
- console.log(`Deleted durable memory namespace user:${userId}.`);
171
+ await rpc.call("flush_namespace", { namespace });
172
+ console.log(`Deleted durable memory namespace ${namespace}.`);
169
173
  } catch (error) {
170
174
  logger.error(`LibraVDB flush failed: ${formatError(error)}`);
171
175
  process.exitCode = 1;
@@ -176,7 +180,7 @@ async function runExport(runtime: PluginRuntime, opts: CliOptionBag | undefined,
176
180
  try {
177
181
  const rpc = await runtime.getRpc();
178
182
  const result = await rpc.call<ExportResult>("export_memory", {
179
- userId: opts?.userId?.trim() || undefined,
183
+ namespace: resolveCliNamespace(opts),
180
184
  });
181
185
  for (const record of result.records ?? []) {
182
186
  stdout.write(`${JSON.stringify(record)}\n`);
@@ -233,6 +237,15 @@ function normalizeLimit(limit: string | number | undefined): number | undefined
233
237
  return undefined;
234
238
  }
235
239
 
240
+ function resolveCliNamespace(opts: CliOptionBag | undefined): string | undefined {
241
+ const userId = opts?.userId?.trim();
242
+ const sessionKey = opts?.sessionKey?.trim();
243
+ if (!userId && !sessionKey) {
244
+ return undefined;
245
+ }
246
+ return resolveDurableNamespace({ userId, sessionKey });
247
+ }
248
+
236
249
  type CliRegistrar = {
237
250
  registerCli?(
238
251
  builder: (ctx: { program: CliProgram }) => void,
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import {
2
3
  DEFAULT_CONTINUITY_MIN_TURNS,
3
4
  DEFAULT_CONTINUITY_PRIOR_CONTEXT_TOKENS,
@@ -19,6 +20,7 @@ import {
19
20
  } from "./temporal.js";
20
21
  import type { TemporalRecoveryRankingResult } from "./temporal.js";
21
22
  import { countTokens, estimateTokens, fitPromptBudget, fitPromptBudgetFirstFit } from "./tokens.js";
23
+ import { resolveDurableNamespace } from "./durable-namespace.js";
22
24
  import type { RpcGetter } from "./plugin-runtime.js";
23
25
  import type {
24
26
  ContextAssembleArgs,
@@ -27,6 +29,7 @@ import type {
27
29
  ContextCompactArgs,
28
30
  ContextIngestArgs,
29
31
  GatingResult,
32
+ MemoryMessage,
30
33
  PluginConfig,
31
34
  RecallCache,
32
35
  SearchResult,
@@ -42,6 +45,8 @@ const SESSION_RAW_COLLECTION_PREFIX = "session_raw:";
42
45
  const SESSION_SUMMARY_COLLECTION_PREFIX = "session_summary:";
43
46
  const SESSION_EDGE_COLLECTION_PREFIX = "session_edge:";
44
47
  const SESSION_STATE_COLLECTION_PREFIX = "session_state:";
48
+ const AFTER_TURN_DEDUPE_TTL_MS = 60 * 60 * 1000;
49
+ const AFTER_TURN_DEDUPE_MAX_ENTRIES = 1024;
45
50
 
46
51
  export function buildContextEngineFactory(
47
52
  getRpc: RpcGetter,
@@ -52,8 +57,9 @@ export function buildContextEngineFactory(
52
57
  let authoredSoftCache: SearchResult[] | null = null;
53
58
  let authoredVariantCache: SearchResult[] | null = null;
54
59
  const authoredVariantRecallCache = new Map<string, SearchResult[]>();
60
+ const afterTurnIngestedKeys = new Map<string, number>();
55
61
 
56
- // Session-scoped elevated-guidance cache keyed by sessionId + generation + userId + queryText
62
+ // Session-scoped elevated-guidance cache keyed by sessionId + generation + durable namespace + queryText
57
63
  const elevatedRecallCache = new Map<string, SearchResult[]>();
58
64
  const elevatedRecallGeneration = new Map<string, number>();
59
65
 
@@ -65,7 +71,8 @@ export function buildContextEngineFactory(
65
71
  return {
66
72
  info: { id: "libravdb-memory", name: "LibraVDB Memory", ownsCompaction: true },
67
73
  ownsCompaction: true,
68
- async bootstrap({ sessionId, userId }: ContextBootstrapArgs) {
74
+ async bootstrap({ sessionId, sessionKey, userId }: ContextBootstrapArgs) {
75
+ const durableNamespace = resolveDurableNamespace({ userId, sessionKey, fallback: `session:${sessionId}` });
69
76
  const rpc = await getRpc();
70
77
  await rpc.call("ensure_collections", {
71
78
  collections: [
@@ -75,8 +82,8 @@ export function buildContextEngineFactory(
75
82
  sessionEdgeCollection(sessionId),
76
83
  sessionStateCollection(sessionId),
77
84
  ...(useSessionRecallProjection(cfg) ? [sessionRecallCollection(sessionId)] : []),
78
- `turns:${userId}`,
79
- `user:${userId}`,
85
+ `turns:${durableNamespace}`,
86
+ `user:${durableNamespace}`,
80
87
  "global",
81
88
  AUTHORED_HARD_COLLECTION,
82
89
  AUTHORED_SOFT_COLLECTION,
@@ -98,110 +105,93 @@ export function buildContextEngineFactory(
98
105
  validateSection7StartupHardReserve(cfg, authoredHard);
99
106
  return { ok: true };
100
107
  },
101
- async ingest({ sessionId, userId, message, isHeartbeat }: ContextIngestArgs) {
108
+ async ingest({ sessionId, sessionKey, userId, message, isHeartbeat }: ContextIngestArgs) {
102
109
  if (isHeartbeat) {
103
110
  return { ingested: false };
104
111
  }
105
112
 
106
- const rpc = await getRpc();
107
- const ts = Date.now();
108
- const sessionMeta = {
109
- role: message.role,
110
- ts,
111
- userId,
112
- sessionId,
113
- type: "turn",
114
- provenance_class: "session_turn",
115
- stability_weight: stabilityWeightForMessage(message.role),
116
- };
117
- // Elevated cache is session-scoped, so invalidate immediately on every ingest
118
- clearElevatedCacheForSession(sessionId);
119
- const rawSessionId = `${sessionId}:${ts}`;
120
- const rawSessionInsert = rpc.call("insert_session_turn", {
113
+ const result = await ingestCanonicalMessage({
114
+ getRpc,
115
+ cfg,
116
+ recallCache,
117
+ clearElevatedCacheForSession,
121
118
  sessionId,
122
- id: rawSessionId,
123
- text: message.content,
124
- metadata: sessionMeta,
119
+ sessionKey,
120
+ userId,
121
+ message,
125
122
  });
126
- if (useSessionRecallProjection(cfg)) {
127
- try {
128
- await rawSessionInsert;
129
- await rebuildSessionRecallProjection(rpc, cfg, sessionId);
130
- } catch (error) {
131
- console.error(error);
132
- }
133
- } else {
134
- void rawSessionInsert.catch(console.error);
123
+ return { ingested: result.ingested };
124
+ },
125
+ async afterTurn({ sessionId, sessionKey, userId, messages, prePromptMessageCount, isHeartbeat }: {
126
+ sessionId: string;
127
+ sessionKey?: string;
128
+ userId?: string;
129
+ messages: Array<{ role: string; content: unknown }>;
130
+ prePromptMessageCount: number;
131
+ isHeartbeat?: boolean;
132
+ }) {
133
+ if (isHeartbeat) {
134
+ return;
135
135
  }
136
136
 
137
- if (message.role === "user") {
138
- try {
139
- recallCache.clearUser(userId);
140
- await rpc.call("insert_text", {
141
- collection: `turns:${userId}`,
142
- id: `${userId}:${ts}`,
143
- text: message.content,
144
- metadata: {
145
- ...sessionMeta,
146
- provenance_class: "turn_index",
147
- },
148
- });
137
+ const startIndex = Math.max(0, prePromptMessageCount - 1);
138
+ const turnMessages = messages.slice(startIndex);
139
+ const normalizedTurnMessages = turnMessages.flatMap((turnMessage, offset) => {
140
+ const normalized = normalizeHostMessage(turnMessage);
141
+ if (!normalized) {
142
+ return [];
143
+ }
144
+ return [{ index: startIndex + offset, normalized }] as const;
145
+ });
146
+ for (let offset = 0; offset < normalizedTurnMessages.length; offset++) {
147
+ const { index, normalized } = normalizedTurnMessages[offset];
149
148
 
150
- const gating = await rpc.call<GatingResult>("gating_scalar", {
151
- userId,
152
- text: message.content,
153
- });
149
+ const dedupeKey = `${sessionId}\n${index}\n${normalized.role}\n${hashMessageContent(normalized.content)}`;
150
+ if (hasRecentAfterTurnIngest(afterTurnIngestedKeys, dedupeKey)) {
151
+ continue;
152
+ }
154
153
 
155
- if (gating.g >= (cfg.ingestionGateThreshold ?? 0.35)) {
156
- void rpc.call("insert_text", {
157
- collection: `user:${userId}`,
158
- id: `${userId}:${ts}`,
159
- text: message.content,
160
- metadata: {
161
- role: message.role,
162
- ts,
163
- sessionId,
164
- type: "turn",
165
- userId,
166
- provenance_class: "durable_user_memory",
167
- stability_weight: Math.max(stabilityWeightForMessage(message.role), gating.g),
168
- gating_score: gating.g,
169
- gating_t: gating.t,
170
- gating_h: gating.h,
171
- gating_r: gating.r,
172
- gating_d: gating.d,
173
- gating_p: gating.p,
174
- gating_a: gating.a,
175
- gating_dtech: gating.dtech,
176
- gating_gconv: gating.gconv,
177
- gating_gtech: gating.gtech,
178
- },
179
- }).catch(console.error);
180
- }
181
- } catch {
182
- // Session storage already happened; skip durable promotion on gating failure.
154
+ const result = await ingestCanonicalMessage({
155
+ getRpc,
156
+ cfg,
157
+ recallCache,
158
+ clearElevatedCacheForSession,
159
+ sessionId,
160
+ sessionKey,
161
+ userId,
162
+ message: {
163
+ ...normalized,
164
+ id: `after-turn:${index}`,
165
+ },
166
+ skipProjectionRebuild: offset !== normalizedTurnMessages.length - 1,
167
+ });
168
+ if (result.ingested) {
169
+ rememberAfterTurnIngest(afterTurnIngestedKeys, dedupeKey);
183
170
  }
184
171
  }
185
-
186
- return { ingested: true };
187
172
  },
188
- async assemble({ sessionId, userId, messages, tokenBudget }: ContextAssembleArgs) {
173
+ async assemble({ sessionId, sessionKey, userId, messages, tokenBudget, ...rest }: ContextAssembleArgs & Record<string, unknown>) {
189
174
  const PROFILE = process.env.OPENCLAW_PROFILE_ASSEMBLE === "1";
190
175
  const DEBUG_RECOVERY = process.env.LONGMEMEVAL_DEBUG_RANKING === "1";
176
+ const durableNamespace = resolveDurableNamespace({ userId, sessionKey, fallback: `session:${sessionId}` });
177
+ const originalMessages = messages;
178
+ const normalizedMessages = normalizeConversationMessages(messages as Array<{ role: string; content: unknown }>);
191
179
 
192
- const queryText = messages.at(-1)?.content ?? "";
180
+ const queryText =
181
+ (typeof rest.prompt === "string" && rest.prompt.trim() ? rest.prompt : undefined) ??
182
+ normalizedMessages.at(-1)?.content ?? "";
193
183
  if (!queryText) {
194
184
  return {
195
- messages,
196
- estimatedTokens: countTokens(messages),
185
+ messages: originalMessages,
186
+ estimatedTokens: countTokens(originalMessages),
197
187
  systemPromptAddition: "",
198
188
  } satisfies ContextAssembleResult;
199
189
  }
200
190
  const temporalQuery = detectTemporalQuerySignal(queryText);
201
191
  const temporalSelectorGuard = decideTemporalSelectorGuard(queryText, temporalQuery);
202
192
 
203
- const excluded = recentIds(messages, 4);
204
- const cached = recallCache.take({ userId, queryText });
193
+ const excluded = recentIds(normalizedMessages, 4);
194
+ const cached = recallCache.take({ userId: durableNamespace, queryText });
205
195
 
206
196
  const rpc = await getRpc();
207
197
 
@@ -265,8 +255,9 @@ export function buildContextEngineFactory(
265
255
  temporalQuery,
266
256
  temporalSelectorGuard,
267
257
  sessionId,
268
- userId,
269
- messages,
258
+ userId: durableNamespace,
259
+ visibleMessages: originalMessages,
260
+ messages: normalizedMessages,
270
261
  tokenBudget,
271
262
  profiler,
272
263
  debugRecovery: DEBUG_RECOVERY,
@@ -282,8 +273,8 @@ export function buildContextEngineFactory(
282
273
  : result;
283
274
  } catch {
284
275
  return {
285
- messages,
286
- estimatedTokens: countTokens(messages),
276
+ messages: originalMessages,
277
+ estimatedTokens: countTokens(originalMessages),
287
278
  systemPromptAddition: "",
288
279
  } satisfies ContextAssembleResult;
289
280
  }
@@ -302,6 +293,7 @@ export function buildContextEngineFactory(
302
293
  temporalSelectorGuard,
303
294
  sessionId,
304
295
  userId,
296
+ visibleMessages,
305
297
  messages,
306
298
  tokenBudget,
307
299
  profiler,
@@ -320,6 +312,7 @@ export function buildContextEngineFactory(
320
312
  temporalSelectorGuard: ReturnType<typeof decideTemporalSelectorGuard>;
321
313
  sessionId: string;
322
314
  userId: string;
315
+ visibleMessages: MemoryMessage[];
323
316
  messages: Array<{ role: string; content: string }>;
324
317
  tokenBudget: number;
325
318
  profiler: { mark(label: string): void; emit(): void } | null;
@@ -369,8 +362,8 @@ export function buildContextEngineFactory(
369
362
  content: buildInjectedMemoryMessageContent(item),
370
363
  }));
371
364
  return {
372
- messages: [...selectedMessages, ...messages],
373
- estimatedTokens: countTokens(selectedMessages) + countTokens(messages),
365
+ messages: [...selectedMessages, ...visibleMessages],
366
+ estimatedTokens: countTokens(selectedMessages) + countTokens(visibleMessages),
374
367
  systemPromptAddition: buildDegradedMemoryHeader(degradedReasons, selected),
375
368
  };
376
369
  }
@@ -706,8 +699,8 @@ export function buildContextEngineFactory(
706
699
  }));
707
700
 
708
701
  return {
709
- messages: [...selectedMessages, ...messages],
710
- estimatedTokens: countTokens(selectedMessages) + countTokens(messages),
702
+ messages: [...selectedMessages, ...visibleMessages],
703
+ estimatedTokens: countTokens(selectedMessages) + countTokens(visibleMessages),
711
704
  systemPromptAddition: buildMemoryHeader(selected),
712
705
  _debug: debugRecovery
713
706
  ? {
@@ -981,6 +974,207 @@ function clampFraction(value: number | undefined): number {
981
974
  return Math.min(1, Math.max(0, value));
982
975
  }
983
976
 
977
+ async function ingestCanonicalMessage(params: {
978
+ getRpc: RpcGetter;
979
+ cfg: PluginConfig;
980
+ recallCache: RecallCache<SearchResult>;
981
+ clearElevatedCacheForSession: (sessionId: string) => void;
982
+ sessionId: string;
983
+ sessionKey?: string;
984
+ userId?: string;
985
+ message: MemoryMessage;
986
+ skipProjectionRebuild?: boolean;
987
+ }): Promise<{ ingested: boolean }> {
988
+ const normalized = normalizeMemoryMessage(params.message);
989
+ if (!normalized) {
990
+ return { ingested: false };
991
+ }
992
+
993
+ const rpc = await params.getRpc();
994
+ const ts = Date.now();
995
+ const durableNamespace = resolveDurableNamespace({
996
+ userId: params.userId,
997
+ sessionKey: params.sessionKey,
998
+ fallback: `session:${params.sessionId}`,
999
+ });
1000
+ const turnId = normalized.id ?? `${ts}`;
1001
+ const sessionMeta = {
1002
+ role: normalized.role,
1003
+ ts,
1004
+ userId: durableNamespace,
1005
+ sessionId: params.sessionId,
1006
+ type: "turn",
1007
+ provenance_class: "session_turn",
1008
+ stability_weight: stabilityWeightForMessage(normalized.role),
1009
+ source_turn_id: turnId,
1010
+ };
1011
+
1012
+ params.clearElevatedCacheForSession(params.sessionId);
1013
+ const rawSessionInsert = rpc.call("insert_session_turn", {
1014
+ sessionId: params.sessionId,
1015
+ id: `${params.sessionId}:${turnId}`,
1016
+ text: normalized.content,
1017
+ metadata: sessionMeta,
1018
+ });
1019
+ try {
1020
+ await rawSessionInsert;
1021
+ if (useSessionRecallProjection(params.cfg) && !params.skipProjectionRebuild) {
1022
+ await rebuildSessionRecallProjection(rpc, params.cfg, params.sessionId);
1023
+ }
1024
+ } catch {
1025
+ return { ingested: false };
1026
+ }
1027
+
1028
+ if (normalized.role !== "user") {
1029
+ return { ingested: true };
1030
+ }
1031
+
1032
+ try {
1033
+ params.recallCache.clearUser(durableNamespace);
1034
+ await rpc.call("insert_text", {
1035
+ collection: `turns:${durableNamespace}`,
1036
+ id: `${durableNamespace}:${turnId}`,
1037
+ text: normalized.content,
1038
+ metadata: {
1039
+ ...sessionMeta,
1040
+ provenance_class: "turn_index",
1041
+ },
1042
+ });
1043
+
1044
+ const gating = await rpc.call<GatingResult>("gating_scalar", {
1045
+ userId: durableNamespace,
1046
+ text: normalized.content,
1047
+ });
1048
+
1049
+ if (gating.g >= (params.cfg.ingestionGateThreshold ?? 0.35)) {
1050
+ void rpc.call("insert_text", {
1051
+ collection: `user:${durableNamespace}`,
1052
+ id: `${durableNamespace}:${turnId}`,
1053
+ text: normalized.content,
1054
+ metadata: {
1055
+ role: normalized.role,
1056
+ ts,
1057
+ sessionId: params.sessionId,
1058
+ type: "turn",
1059
+ userId: durableNamespace,
1060
+ source_turn_id: turnId,
1061
+ provenance_class: "durable_user_memory",
1062
+ stability_weight: Math.max(stabilityWeightForMessage(normalized.role), gating.g),
1063
+ gating_score: gating.g,
1064
+ gating_t: gating.t,
1065
+ gating_h: gating.h,
1066
+ gating_r: gating.r,
1067
+ gating_d: gating.d,
1068
+ gating_p: gating.p,
1069
+ gating_a: gating.a,
1070
+ gating_dtech: gating.dtech,
1071
+ gating_gconv: gating.gconv,
1072
+ gating_gtech: gating.gtech,
1073
+ },
1074
+ }).catch(console.error);
1075
+ }
1076
+ } catch {
1077
+ // Session storage already happened; skip durable promotion on gating failure.
1078
+ }
1079
+
1080
+ return { ingested: true };
1081
+ }
1082
+
1083
+ function hashMessageContent(content: string): string {
1084
+ return createHash("sha256").update(content).digest("hex");
1085
+ }
1086
+
1087
+ function pruneAfterTurnIngestKeys(cache: Map<string, number>, now: number): void {
1088
+ for (const [key, seenAt] of cache) {
1089
+ if (now - seenAt > AFTER_TURN_DEDUPE_TTL_MS) {
1090
+ cache.delete(key);
1091
+ }
1092
+ }
1093
+ while (cache.size > AFTER_TURN_DEDUPE_MAX_ENTRIES) {
1094
+ const oldestKey = cache.keys().next().value;
1095
+ if (!oldestKey) {
1096
+ break;
1097
+ }
1098
+ cache.delete(oldestKey);
1099
+ }
1100
+ }
1101
+
1102
+ function hasRecentAfterTurnIngest(cache: Map<string, number>, key: string): boolean {
1103
+ const now = Date.now();
1104
+ pruneAfterTurnIngestKeys(cache, now);
1105
+ return cache.has(key);
1106
+ }
1107
+
1108
+ function rememberAfterTurnIngest(cache: Map<string, number>, key: string): void {
1109
+ const now = Date.now();
1110
+ cache.delete(key);
1111
+ cache.set(key, now);
1112
+ pruneAfterTurnIngestKeys(cache, now);
1113
+ }
1114
+
1115
+ function normalizeHostMessage(message: { role: string; content: unknown } | undefined): MemoryMessage | null {
1116
+ if (!message || !shouldIngestRole(message.role)) {
1117
+ return null;
1118
+ }
1119
+ const content = extractMessageText(message.content);
1120
+ if (!content) {
1121
+ return null;
1122
+ }
1123
+ return {
1124
+ role: message.role,
1125
+ content,
1126
+ };
1127
+ }
1128
+
1129
+ function normalizeConversationMessages(messages: Array<{ role: string; content: unknown }>): MemoryMessage[] {
1130
+ return messages
1131
+ .map((message) => normalizeHostMessage(message))
1132
+ .filter((message): message is MemoryMessage => message !== null);
1133
+ }
1134
+
1135
+ function normalizeMemoryMessage(message: MemoryMessage): MemoryMessage | null {
1136
+ if (!shouldIngestRole(message.role)) {
1137
+ return null;
1138
+ }
1139
+ const content = extractMessageText(message.content);
1140
+ if (!content) {
1141
+ return null;
1142
+ }
1143
+ return {
1144
+ ...message,
1145
+ content,
1146
+ };
1147
+ }
1148
+
1149
+ function shouldIngestRole(role: string): boolean {
1150
+ return role === "user" || role === "assistant" || role === "system";
1151
+ }
1152
+
1153
+ function extractMessageText(content: unknown): string {
1154
+ if (typeof content === "string") {
1155
+ return content;
1156
+ }
1157
+ if (!Array.isArray(content)) {
1158
+ return "";
1159
+ }
1160
+ return content
1161
+ .flatMap((part) => {
1162
+ if (!part || typeof part !== "object") {
1163
+ return [];
1164
+ }
1165
+ const type = (part as { type?: unknown }).type;
1166
+ if (
1167
+ (type === "text" || type === "input_text" || type === "output_text") &&
1168
+ typeof (part as { text?: unknown }).text === "string"
1169
+ ) {
1170
+ return [(part as { text: string }).text];
1171
+ }
1172
+ return [];
1173
+ })
1174
+ .join("\n")
1175
+ .trim();
1176
+ }
1177
+
984
1178
  function validateSection7StartupHardReserve(cfg: PluginConfig, authoredHard: SearchResult[]): void {
985
1179
  if (authoredHard.length === 0) {
986
1180
  return;
@@ -0,0 +1,34 @@
1
+ const SESSION_KEY_NAMESPACE_PREFIX = "session-key:";
2
+ const AGENT_ID_NAMESPACE_PREFIX = "agent-id:";
3
+
4
+ export function resolveDurableNamespace(params: {
5
+ userId?: string;
6
+ sessionKey?: string;
7
+ agentId?: string;
8
+ fallback?: string;
9
+ }): string {
10
+ const explicitUserId = firstNonEmpty(params.userId);
11
+ if (explicitUserId) {
12
+ return explicitUserId;
13
+ }
14
+
15
+ const sessionKey = firstNonEmpty(params.sessionKey);
16
+ if (sessionKey) {
17
+ return `${SESSION_KEY_NAMESPACE_PREFIX}${sessionKey}`;
18
+ }
19
+
20
+ const agentId = firstNonEmpty(params.agentId);
21
+ if (agentId) {
22
+ return `${AGENT_ID_NAMESPACE_PREFIX}${agentId}`;
23
+ }
24
+
25
+ return firstNonEmpty(params.fallback) ?? "default";
26
+ }
27
+
28
+ function firstNonEmpty(value: string | undefined): string | undefined {
29
+ if (typeof value !== "string") {
30
+ return undefined;
31
+ }
32
+ const trimmed = value.trim();
33
+ return trimmed.length > 0 ? trimmed : undefined;
34
+ }
@@ -1,4 +1,5 @@
1
1
  import type { RpcGetter } from "./plugin-runtime.js";
2
+ import { resolveDurableNamespace } from "./durable-namespace.js";
2
3
  import type { PluginConfig, SearchResult } from "./types.js";
3
4
 
4
5
  type MemorySearchParams = {
@@ -12,10 +13,12 @@ type MemorySearchParams = {
12
13
  userId?: string;
13
14
  agentId?: string;
14
15
  sessionId?: string;
16
+ sessionKey?: string;
15
17
  context?: {
16
18
  userId?: string;
17
19
  agentId?: string;
18
20
  sessionId?: string;
21
+ sessionKey?: string;
19
22
  };
20
23
  };
21
24
 
@@ -32,8 +35,9 @@ type MemoryRuntimeStatus = {
32
35
  export function buildMemoryRuntimeBridge(getRpc: RpcGetter, cfg: PluginConfig) {
33
36
  return {
34
37
  async getMemorySearchManager(params: { agentId?: string; purpose?: string } = {}) {
38
+ const status = await readStatus(getRpc, params.purpose);
35
39
  return {
36
- manager: createMemorySearchManager(getRpc, cfg, params),
40
+ manager: createMemorySearchManager(getRpc, cfg, params, status),
37
41
  };
38
42
  },
39
43
  resolveMemoryBackendConfig() {
@@ -51,23 +55,36 @@ function createMemorySearchManager(
51
55
  getRpc: RpcGetter,
52
56
  cfg: PluginConfig,
53
57
  defaults: { agentId?: string; purpose?: string },
58
+ initialStatus: MemoryRuntimeStatus & Record<string, unknown>,
54
59
  ) {
60
+ let cachedStatus = initialStatus;
61
+
55
62
  return {
56
- async search(params: MemorySearchParams = {}) {
63
+ async search(queryOrParams: string | MemorySearchParams = {}, opts: MemorySearchParams = {}) {
64
+ const legacyCall = typeof queryOrParams === "string";
65
+ const params = legacyCall
66
+ ? {
67
+ query: queryOrParams,
68
+ limit: opts.limit ?? opts.k ?? opts.topK,
69
+ sessionId: opts.sessionId,
70
+ sessionKey: opts.sessionKey,
71
+ userId: opts.userId,
72
+ agentId: opts.agentId,
73
+ context: opts.context,
74
+ }
75
+ : queryOrParams;
57
76
  const queryText = firstString(params.query, params.text, params.input, params.q);
58
77
  if (!queryText) {
59
- return { results: [], error: "Missing query text for LibraVDB memory search" };
78
+ return legacyCall ? { results: [], error: "Missing query text for LibraVDB memory search" } : [];
60
79
  }
61
80
 
62
- const userId = firstString(
63
- params.userId,
64
- params.context?.userId,
65
- params.agentId,
66
- params.context?.agentId,
67
- defaults.agentId,
68
- "default",
69
- )!;
70
81
  const sessionId = firstString(params.sessionId, params.context?.sessionId);
82
+ const userId = resolveDurableNamespace({
83
+ userId: firstString(params.userId, params.context?.userId),
84
+ sessionKey: firstString(params.sessionKey, params.context?.sessionKey),
85
+ agentId: firstString(params.agentId, params.context?.agentId, defaults.agentId),
86
+ fallback: sessionId ? `session:${sessionId}` : undefined,
87
+ });
71
88
  const k = normalizePositiveInteger(params.k, params.limit, params.topK, cfg.topK, 8);
72
89
  const collections = resolveSearchCollections(cfg, userId, sessionId);
73
90
  const rpc = await getRpc();
@@ -85,11 +102,24 @@ function createMemorySearchManager(
85
102
  excludeByCollection: {},
86
103
  });
87
104
 
105
+ const legacyResults = result.results.map((item) => ({
106
+ ...item,
107
+ content: item.text,
108
+ }));
109
+ if (legacyCall) {
110
+ return { results: legacyResults };
111
+ }
112
+ return result.results.map(toMemorySearchResult);
113
+ },
114
+ async readFile(params: { relPath: string; from?: number; lines?: number }) {
115
+ const located = await loadSearchResultText(getRpc, params.relPath);
116
+ const fromLine = Math.max(1, params.from ?? 1);
117
+ const lineCount = Math.max(1, params.lines ?? 200);
118
+ const lines = located.text.split("\n");
119
+ const text = lines.slice(fromLine - 1, fromLine - 1 + lineCount).join("\n");
88
120
  return {
89
- results: result.results.map((item) => ({
90
- ...item,
91
- content: item.text,
92
- })),
121
+ text,
122
+ path: located.path,
93
123
  };
94
124
  },
95
125
  async ingest() {
@@ -97,24 +127,26 @@ function createMemorySearchManager(
97
127
  return { ingested: false, delegatedToContextEngine: true };
98
128
  },
99
129
  async sync() {
100
- // Projections and compaction sync are already handled inside the existing
101
- // context-engine lifecycle.
130
+ cachedStatus = await readStatus(getRpc, defaults.purpose);
102
131
  return { synced: true, delegatedToContextEngine: true };
103
132
  },
104
- async status() {
105
- const rpc = await getRpc();
106
- const status = await rpc.call<MemoryRuntimeStatus>("status", {});
133
+ status() {
134
+ return cachedStatus;
135
+ },
136
+ async probeEmbeddingAvailability() {
107
137
  return {
108
- ok: status.ok ?? false,
109
- message: status.message ?? "ok",
110
- turnCount: status.turnCount ?? 0,
111
- memoryCount: status.memoryCount ?? 0,
112
- gatingThreshold: status.gatingThreshold,
113
- abstractiveReady: status.abstractiveReady ?? false,
114
- embeddingProfile: status.embeddingProfile ?? "unknown",
115
- purpose: defaults.purpose,
138
+ ok: cachedStatus.ok ?? false,
139
+ ...(cachedStatus.ok === false && typeof cachedStatus.message === "string"
140
+ ? { error: cachedStatus.message }
141
+ : {}),
116
142
  };
117
143
  },
144
+ async probeVectorAvailability() {
145
+ return cachedStatus.ok ?? false;
146
+ },
147
+ async close() {
148
+ // The sidecar connection is shared by the plugin runtime.
149
+ },
118
150
  };
119
151
  }
120
152
 
@@ -140,6 +172,84 @@ function firstString(...values: Array<string | undefined>): string | undefined {
140
172
  return values.find((value) => typeof value === "string" && value.length > 0);
141
173
  }
142
174
 
175
+ function toMemorySearchResult(item: SearchResult) {
176
+ const collection = typeof item.metadata.collection === "string" ? item.metadata.collection : "memory";
177
+ return {
178
+ path: encodeSearchResultPath(collection, item.id),
179
+ startLine: 1,
180
+ endLine: Math.max(1, item.text.split("\n").length),
181
+ score: item.score,
182
+ snippet: item.text,
183
+ source: collection.startsWith("session:") || collection.startsWith("session_") ? "sessions" : "memory",
184
+ citation: `${collection}:${item.id}`,
185
+ };
186
+ }
187
+
188
+ async function loadSearchResultText(getRpc: RpcGetter, relPath: string): Promise<{ path: string; text: string }> {
189
+ const { collection, id } = decodeSearchResultPath(relPath);
190
+ const rpc = await getRpc();
191
+ const result = await rpc.call<{ results: SearchResult[] }>("list_collection", { collection });
192
+ const item = result.results.find((entry) => entry.id === id);
193
+ if (!item) {
194
+ throw new Error(`LibraVDB memory path not found: ${relPath}`);
195
+ }
196
+ return {
197
+ path: relPath,
198
+ text: item.text,
199
+ };
200
+ }
201
+
202
+ function encodeSearchResultPath(collection: string, id: string): string {
203
+ return `${encodeURIComponent(collection)}::${encodeURIComponent(id)}`;
204
+ }
205
+
206
+ function decodeSearchResultPath(relPath: string): { collection: string; id: string } {
207
+ const separator = relPath.indexOf("::");
208
+ if (separator <= 0) {
209
+ throw new Error(`Unsupported LibraVDB memory path: ${relPath}`);
210
+ }
211
+ return {
212
+ collection: decodeURIComponent(relPath.slice(0, separator)),
213
+ id: decodeURIComponent(relPath.slice(separator + 2)),
214
+ };
215
+ }
216
+
217
+ async function readStatus(
218
+ getRpc: RpcGetter,
219
+ purpose: string | undefined,
220
+ ): Promise<MemoryRuntimeStatus & Record<string, unknown>> {
221
+ try {
222
+ const rpc = await getRpc();
223
+ const status = await rpc.call<MemoryRuntimeStatus & Record<string, unknown>>("status", {});
224
+ return {
225
+ ...status,
226
+ backend: "builtin",
227
+ provider: "libravdb",
228
+ model: status.embeddingProfile ?? "unknown",
229
+ ok: status.ok ?? false,
230
+ message: status.message ?? "ok",
231
+ turnCount: status.turnCount ?? 0,
232
+ memoryCount: status.memoryCount ?? 0,
233
+ gatingThreshold: status.gatingThreshold,
234
+ abstractiveReady: status.abstractiveReady ?? false,
235
+ embeddingProfile: status.embeddingProfile ?? "unknown",
236
+ purpose,
237
+ };
238
+ } catch (error) {
239
+ return {
240
+ backend: "builtin",
241
+ provider: "libravdb",
242
+ model: "unknown",
243
+ ok: false,
244
+ message: error instanceof Error && error.message ? error.message : String(error),
245
+ turnCount: 0,
246
+ memoryCount: 0,
247
+ embeddingProfile: "unknown",
248
+ purpose,
249
+ };
250
+ }
251
+ }
252
+
143
253
  function normalizePositiveInteger(...values: Array<number | undefined>): number {
144
254
  for (const value of values) {
145
255
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
package/src/types.ts CHANGED
@@ -172,21 +172,20 @@ export interface RecallCache<T = unknown> {
172
172
  clearUser(userId: string): void;
173
173
  }
174
174
 
175
- export interface ContextBootstrapArgs {
175
+ export interface ContextNamespaceArgs {
176
176
  sessionId: string;
177
- userId: string;
177
+ sessionKey?: string;
178
+ userId?: string;
178
179
  }
179
180
 
180
- export interface ContextIngestArgs {
181
- sessionId: string;
182
- userId: string;
181
+ export interface ContextBootstrapArgs extends ContextNamespaceArgs {}
182
+
183
+ export interface ContextIngestArgs extends ContextNamespaceArgs {
183
184
  message: MemoryMessage;
184
185
  isHeartbeat?: boolean;
185
186
  }
186
187
 
187
- export interface ContextAssembleArgs {
188
- sessionId: string;
189
- userId: string;
188
+ export interface ContextAssembleArgs extends ContextNamespaceArgs {
190
189
  messages: MemoryMessage[];
191
190
  tokenBudget: number;
192
191
  }