@xdarkicex/openclaw-memory-libravdb 1.4.4 → 1.4.6

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.
@@ -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.4.4",
5
+ "version": "1.4.6",
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.4",
3
+ "version": "1.4.6",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,6 +31,7 @@ import type {
31
31
  ContextCompactArgs,
32
32
  ContextIngestArgs,
33
33
  GatingResult,
34
+ LoggerLike,
34
35
  MemoryMessage,
35
36
  PluginConfig,
36
37
  RecallCache,
@@ -50,10 +51,81 @@ const SESSION_STATE_COLLECTION_PREFIX = "session_state:";
50
51
  const AFTER_TURN_DEDUPE_TTL_MS = 60 * 60 * 1000;
51
52
  const AFTER_TURN_DEDUPE_MAX_ENTRIES = 1024;
52
53
 
54
+ function mapTemporalRecoveryDebugCandidates(
55
+ ranking: TemporalRecoveryRankingResult,
56
+ ): NonNullable<NonNullable<ContextAssembleResult["_debug"]>["rawUserRecoveryCandidates"]> {
57
+ return ranking.debug.slice(0, 8).map((item) => ({
58
+ id: item.id,
59
+ text: item.text,
60
+ selected: "selected" in item ? Boolean(item.selected) : false,
61
+ tokenEstimate: estimateTokens(item.text),
62
+ temporalAnchorDensity: "temporalAnchorDensity" in item && typeof item.temporalAnchorDensity === "number"
63
+ ? item.temporalAnchorDensity
64
+ : 0,
65
+ semanticScore: "semanticScore" in item && typeof item.semanticScore === "number"
66
+ ? item.semanticScore
67
+ : 0,
68
+ slotCoverage: "slotCoverage" in item && typeof item.slotCoverage === "number"
69
+ ? item.slotCoverage
70
+ : undefined,
71
+ slotMatches: "slotMatches" in item && Array.isArray(item.slotMatches)
72
+ ? item.slotMatches
73
+ : undefined,
74
+ lexicalCoverage: "lexicalCoverage" in item && typeof item.lexicalCoverage === "number"
75
+ ? item.lexicalCoverage
76
+ : ("slotCoverage" in item && typeof item.slotCoverage === "number" ? item.slotCoverage : 0),
77
+ recencyScore: "recencyScore" in item && typeof item.recencyScore === "number"
78
+ ? item.recencyScore
79
+ : 0,
80
+ finalScore: typeof item.finalScore === "number" ? item.finalScore : 0,
81
+ rationale: typeof item.rationale === "string" ? item.rationale : "",
82
+ comparisonSide: "comparisonSide" in item && (item.comparisonSide === 0 || item.comparisonSide === 1 || item.comparisonSide === null)
83
+ ? item.comparisonSide
84
+ : undefined,
85
+ comparisonSlot: "comparisonSlot" in item && typeof item.comparisonSlot === "string"
86
+ ? item.comparisonSlot
87
+ : undefined,
88
+ comparisonSlotRecall: "comparisonSlotRecall" in item && typeof item.comparisonSlotRecall === "number"
89
+ ? item.comparisonSlotRecall
90
+ : undefined,
91
+ comparisonSlotPrecision: "comparisonSlotPrecision" in item && typeof item.comparisonSlotPrecision === "number"
92
+ ? item.comparisonSlotPrecision
93
+ : undefined,
94
+ comparisonSlotSpecificity: "comparisonSlotSpecificity" in item && typeof item.comparisonSlotSpecificity === "number"
95
+ ? item.comparisonSlotSpecificity
96
+ : undefined,
97
+ comparisonSlotPositionWeightedRecall: "comparisonSlotPositionWeightedRecall" in item && typeof item.comparisonSlotPositionWeightedRecall === "number"
98
+ ? item.comparisonSlotPositionWeightedRecall
99
+ : undefined,
100
+ comparisonSlotPositionWeightedPrecision: "comparisonSlotPositionWeightedPrecision" in item && typeof item.comparisonSlotPositionWeightedPrecision === "number"
101
+ ? item.comparisonSlotPositionWeightedPrecision
102
+ : undefined,
103
+ comparisonSlotPositionWeightedSpecificity: "comparisonSlotPositionWeightedSpecificity" in item && typeof item.comparisonSlotPositionWeightedSpecificity === "number"
104
+ ? item.comparisonSlotPositionWeightedSpecificity
105
+ : undefined,
106
+ comparisonFirstPersonClauseCount: "comparisonFirstPersonClauseCount" in item && typeof item.comparisonFirstPersonClauseCount === "number"
107
+ ? item.comparisonFirstPersonClauseCount
108
+ : undefined,
109
+ comparisonProspectivePersonalVerbCount: "comparisonProspectivePersonalVerbCount" in item && typeof item.comparisonProspectivePersonalVerbCount === "number"
110
+ ? item.comparisonProspectivePersonalVerbCount
111
+ : undefined,
112
+ comparisonPlanningDensity: "comparisonPlanningDensity" in item && typeof item.comparisonPlanningDensity === "number"
113
+ ? item.comparisonPlanningDensity
114
+ : undefined,
115
+ comparisonPastness: "comparisonPastness" in item && typeof item.comparisonPastness === "number"
116
+ ? item.comparisonPastness
117
+ : undefined,
118
+ comparisonSideWitnessScore: "comparisonSideWitnessScore" in item && typeof item.comparisonSideWitnessScore === "number"
119
+ ? item.comparisonSideWitnessScore
120
+ : undefined,
121
+ }));
122
+ }
123
+
53
124
  export function buildContextEngineFactory(
54
125
  getRpc: RpcGetter,
55
126
  cfg: PluginConfig,
56
127
  recallCache: RecallCache<SearchResult>,
128
+ logger: LoggerLike = console,
57
129
  ) {
58
130
  let authoredHardCache: SearchResult[] | null = null;
59
131
  let authoredSoftCache: SearchResult[] | null = null;
@@ -77,6 +149,9 @@ export function buildContextEngineFactory(
77
149
  ownsCompaction: true,
78
150
  async bootstrap({ sessionId, sessionKey, userId }: ContextBootstrapArgs) {
79
151
  const durableNamespace = resolveDurableNamespace({ userId, sessionKey, fallback: `session:${sessionId}` });
152
+ logger.info?.(
153
+ `[libravdb] bootstrap sessionId=${sessionId} userId=${userId ?? "(none)"} sessionKey=${sessionKey ?? "(none)"} → durable=${durableNamespace}`,
154
+ );
80
155
  const rpc = await getRpc();
81
156
  await rpc.call("ensure_collections", {
82
157
  collections: [
@@ -117,6 +192,7 @@ export function buildContextEngineFactory(
117
192
  const result = await ingestCanonicalMessage({
118
193
  getRpc,
119
194
  cfg,
195
+ logger,
120
196
  recallCache,
121
197
  clearElevatedCacheForSession,
122
198
  sessionId,
@@ -158,6 +234,7 @@ export function buildContextEngineFactory(
158
234
  const result = await ingestCanonicalMessage({
159
235
  getRpc,
160
236
  cfg,
237
+ logger,
161
238
  recallCache,
162
239
  clearElevatedCacheForSession,
163
240
  sessionId,
@@ -255,7 +332,7 @@ export function buildContextEngineFactory(
255
332
  },
256
333
  emit() {
257
334
  for (const line of this.lines()) {
258
- console.log(line);
335
+ logger.info?.(line);
259
336
  }
260
337
  },
261
338
  };
@@ -279,7 +356,7 @@ export function buildContextEngineFactory(
279
356
  comparisonExperiment,
280
357
  emitComparisonProfile,
281
358
  sessionId,
282
- userId: durableNamespace,
359
+ durableNamespace,
283
360
  visibleMessages: originalMessages,
284
361
  messages: normalizedMessages,
285
362
  tokenBudget,
@@ -319,7 +396,7 @@ export function buildContextEngineFactory(
319
396
  comparisonExperiment,
320
397
  emitComparisonProfile,
321
398
  sessionId,
322
- userId,
399
+ durableNamespace,
323
400
  visibleMessages,
324
401
  messages,
325
402
  tokenBudget,
@@ -341,7 +418,7 @@ export function buildContextEngineFactory(
341
418
  comparisonExperiment: ReturnType<typeof resolveComparisonExperimentConfig>;
342
419
  emitComparisonProfile: boolean;
343
420
  sessionId: string;
344
- userId: string;
421
+ durableNamespace: string;
345
422
  visibleMessages: MemoryMessage[];
346
423
  messages: Array<{ role: string; content: string }>;
347
424
  tokenBudget: number;
@@ -352,7 +429,7 @@ export function buildContextEngineFactory(
352
429
  const hardItems = authoredHard;
353
430
  const hardUsed = tokenCostSum(hardItems);
354
431
  const dreamMode = dreamQuery.active;
355
- const dreamCollection = resolveDreamCollection(userId);
432
+ const dreamCollection = resolveDreamCollection(durableNamespace);
356
433
 
357
434
  if (dreamMode) {
358
435
  const authoredSoftTarget = Math.max(0, memoryBudget * (cfg.authoredSoftBudgetFraction ?? 0.3));
@@ -382,7 +459,7 @@ export function buildContextEngineFactory(
382
459
  authorityFrequencyWeight: cfg.section7AuthorityFrequencyWeight,
383
460
  authorityAuthoredWeight: cfg.section7AuthorityAuthoredWeight,
384
461
  sessionId,
385
- userId,
462
+ userId: durableNamespace,
386
463
  },
387
464
  );
388
465
  const dreamItems = fitPromptBudget(rankedDream, remainingBudget);
@@ -507,7 +584,7 @@ export function buildContextEngineFactory(
507
584
  : cached?.userHits
508
585
  ? Promise.resolve({ results: cached.userHits })
509
586
  : rpc.call<{ results: SearchResult[] }>("search_text", {
510
- collection: `user:${userId}`,
587
+ collection: `user:${durableNamespace}`,
511
588
  text: queryText,
512
589
  k: Math.ceil((cfg.topK ?? 8) / 2),
513
590
  }),
@@ -522,9 +599,17 @@ export function buildContextEngineFactory(
522
599
  }),
523
600
  ]);
524
601
 
602
+ if (!dreamMode) {
603
+ logger.info?.(
604
+ `[libravdb] assemble recall durable=${durableNamespace} session=${sessionSearchCollection} sessionHits=${sessionHits.results.length} userHits=${userHits.results.length} globalHits=${globalHits.results.length}`,
605
+ );
606
+ }
607
+
608
+ let temporalRecoveryPreviewDebug: NonNullable<NonNullable<ContextAssembleResult["_debug"]>["rawUserRecoveryCandidates"]> = [];
609
+
525
610
  if (!cached && !dreamMode) {
526
611
  recallCache.put({
527
- userId,
612
+ userId: durableNamespace,
528
613
  queryText,
529
614
  durableVariantHits: [],
530
615
  userHits: userHits.results,
@@ -552,7 +637,7 @@ export function buildContextEngineFactory(
552
637
 
553
638
  profiler?.mark("recall_elevated");
554
639
  const elevatedGeneration = elevatedRecallGeneration.get(sessionId) ?? 0;
555
- const elevatedKey = `${sessionId}\n${elevatedGeneration}\n${userId}\n${queryText}`;
640
+ const elevatedKey = `${sessionId}\n${elevatedGeneration}\n${durableNamespace}\n${queryText}`;
556
641
  const cachedElevated = elevatedRecallCache.get(elevatedKey);
557
642
  const [elevatedHits] = await Promise.all([
558
643
  dreamMode
@@ -561,7 +646,7 @@ export function buildContextEngineFactory(
561
646
  ? Promise.resolve({ results: cachedElevated })
562
647
  : rpc.call<{ results: SearchResult[] }>("search_text_collections", {
563
648
  collections: [
564
- `${ELEVATED_USER_COLLECTION_PREFIX}${userId}`,
649
+ `${ELEVATED_USER_COLLECTION_PREFIX}${durableNamespace}`,
565
650
  `${ELEVATED_SESSION_COLLECTION_PREFIX}${sessionId}`,
566
651
  ],
567
652
  text: queryText,
@@ -573,6 +658,17 @@ export function buildContextEngineFactory(
573
658
  elevatedRecallCache.set(elevatedKey, elevatedHits.results);
574
659
  }
575
660
 
661
+ if (process.env.LONGMEMEVAL_DEBUG_RANKING === "1" && temporalSelectorGuard.shouldApply && userHits.results.length > 0) {
662
+ const previewRanking = rankTemporalRecoveryCandidates(annotateCollection(userHits.results, `user:${durableNamespace}`), {
663
+ queryText,
664
+ maxSelected: 3,
665
+ nowMs: Date.now(),
666
+ recencyLambda: cfg.recencyLambdaUser ?? 0.00001,
667
+ selectionTokenBudget: Math.max(1, Math.min(memoryBudget, retrievalBudget)),
668
+ });
669
+ temporalRecoveryPreviewDebug = mapTemporalRecoveryDebugCandidates(previewRanking);
670
+ }
671
+
576
672
  profiler?.mark("rank");
577
673
  const ranked = rankSection7VariantCandidates(
578
674
  [
@@ -593,7 +689,7 @@ export function buildContextEngineFactory(
593
689
  authorityFrequencyWeight: cfg.section7AuthorityFrequencyWeight,
594
690
  authorityAuthoredWeight: cfg.section7AuthorityAuthoredWeight,
595
691
  sessionId,
596
- userId,
692
+ userId: durableNamespace,
597
693
  },
598
694
  );
599
695
 
@@ -706,12 +802,12 @@ export function buildContextEngineFactory(
706
802
  // coarse for exact-turn recall. Search the immutable per-user raw turn index instead of
707
803
  // widening topK so precise historical turns still have a bounded path back into context.
708
804
  const rawUserResults = await rpc.call<{ results: SearchResult[] }>("search_text", {
709
- collection: `turns:${userId}`,
805
+ collection: `turns:${durableNamespace}`,
710
806
  text: queryText,
711
807
  k: Math.max((cfg.topK ?? 8) * 4, 8),
712
808
  excludeIds: recoveryExcludeIDs,
713
809
  });
714
- const annotatedUserResults = annotateCollection(rawUserResults.results ?? [], `turns:${userId}`);
810
+ const annotatedUserResults = annotateCollection(rawUserResults.results ?? [], `turns:${durableNamespace}`);
715
811
  temporalRecoveryResult = temporalSelectorGuard.shouldApply
716
812
  ? rankTemporalRecoveryCandidates(annotatedUserResults, {
717
813
  queryText,
@@ -728,7 +824,10 @@ export function buildContextEngineFactory(
728
824
  rawUserRecoveryDebug = reranked.debug.slice(0, 8).map((item) => ({
729
825
  id: item.id,
730
826
  text: item.text,
731
- selected: false,
827
+ // This debug surface mirrors the recovery ranker itself. Packing and dedupe can
828
+ // legitimately drop a ranked candidate later, but the host-flow temporal test needs
829
+ // to see what the ranker selected before those downstream filters run.
830
+ selected: "selected" in item ? Boolean(item.selected) : false,
732
831
  tokenEstimate: estimateTokens(item.text),
733
832
  temporalAnchorDensity: "temporalAnchorDensity" in item && typeof item.temporalAnchorDensity === "number"
734
833
  ? item.temporalAnchorDensity
@@ -790,6 +889,90 @@ export function buildContextEngineFactory(
790
889
  ? item.comparisonSideWitnessScore
791
890
  : undefined,
792
891
  }));
892
+
893
+ // If the raw-turn recovery path gets fully deduped because the same turns were already
894
+ // surfaced through the normal durable user collection, expose the ranker output from
895
+ // that durable view as debug-only fallback. This keeps the host-flow temporal test
896
+ // aligned with the actual ranking behavior without changing prompt assembly.
897
+ if (rawUserRecoveryDebug.length === 0 && userHits.results.length > 0) {
898
+ const debugUserRanking = temporalRecoveryResult
899
+ ? temporalRecoveryResult
900
+ : rankTemporalRecoveryCandidates(
901
+ annotateCollection(userHits.results, `user:${durableNamespace}`),
902
+ {
903
+ queryText,
904
+ maxSelected: 3,
905
+ nowMs: Date.now(),
906
+ recencyLambda: cfg.recencyLambdaUser ?? 0.00001,
907
+ selectionTokenBudget: recoveryReserveTokens,
908
+ },
909
+ );
910
+ rawUserRecoveryDebug = debugUserRanking.debug.slice(0, 8).map((item) => ({
911
+ id: item.id,
912
+ text: item.text,
913
+ selected: "selected" in item ? Boolean(item.selected) : false,
914
+ tokenEstimate: estimateTokens(item.text),
915
+ temporalAnchorDensity: "temporalAnchorDensity" in item && typeof item.temporalAnchorDensity === "number"
916
+ ? item.temporalAnchorDensity
917
+ : 0,
918
+ semanticScore: "semanticScore" in item && typeof item.semanticScore === "number"
919
+ ? item.semanticScore
920
+ : 0,
921
+ slotCoverage: "slotCoverage" in item && typeof item.slotCoverage === "number"
922
+ ? item.slotCoverage
923
+ : undefined,
924
+ slotMatches: "slotMatches" in item && Array.isArray(item.slotMatches)
925
+ ? item.slotMatches
926
+ : undefined,
927
+ lexicalCoverage: "lexicalCoverage" in item && typeof item.lexicalCoverage === "number"
928
+ ? item.lexicalCoverage
929
+ : ("slotCoverage" in item && typeof item.slotCoverage === "number" ? item.slotCoverage : 0),
930
+ recencyScore: "recencyScore" in item && typeof item.recencyScore === "number"
931
+ ? item.recencyScore
932
+ : 0,
933
+ finalScore: typeof item.finalScore === "number" ? item.finalScore : 0,
934
+ rationale: typeof item.rationale === "string" ? item.rationale : "",
935
+ comparisonSide: "comparisonSide" in item && (item.comparisonSide === 0 || item.comparisonSide === 1 || item.comparisonSide === null)
936
+ ? item.comparisonSide
937
+ : undefined,
938
+ comparisonSlot: "comparisonSlot" in item && typeof item.comparisonSlot === "string"
939
+ ? item.comparisonSlot
940
+ : undefined,
941
+ comparisonSlotRecall: "comparisonSlotRecall" in item && typeof item.comparisonSlotRecall === "number"
942
+ ? item.comparisonSlotRecall
943
+ : undefined,
944
+ comparisonSlotPrecision: "comparisonSlotPrecision" in item && typeof item.comparisonSlotPrecision === "number"
945
+ ? item.comparisonSlotPrecision
946
+ : undefined,
947
+ comparisonSlotSpecificity: "comparisonSlotSpecificity" in item && typeof item.comparisonSlotSpecificity === "number"
948
+ ? item.comparisonSlotSpecificity
949
+ : undefined,
950
+ comparisonSlotPositionWeightedRecall: "comparisonSlotPositionWeightedRecall" in item && typeof item.comparisonSlotPositionWeightedRecall === "number"
951
+ ? item.comparisonSlotPositionWeightedRecall
952
+ : undefined,
953
+ comparisonSlotPositionWeightedPrecision: "comparisonSlotPositionWeightedPrecision" in item && typeof item.comparisonSlotPositionWeightedPrecision === "number"
954
+ ? item.comparisonSlotPositionWeightedPrecision
955
+ : undefined,
956
+ comparisonSlotPositionWeightedSpecificity: "comparisonSlotPositionWeightedSpecificity" in item && typeof item.comparisonSlotPositionWeightedSpecificity === "number"
957
+ ? item.comparisonSlotPositionWeightedSpecificity
958
+ : undefined,
959
+ comparisonFirstPersonClauseCount: "comparisonFirstPersonClauseCount" in item && typeof item.comparisonFirstPersonClauseCount === "number"
960
+ ? item.comparisonFirstPersonClauseCount
961
+ : undefined,
962
+ comparisonProspectivePersonalVerbCount: "comparisonProspectivePersonalVerbCount" in item && typeof item.comparisonProspectivePersonalVerbCount === "number"
963
+ ? item.comparisonProspectivePersonalVerbCount
964
+ : undefined,
965
+ comparisonPlanningDensity: "comparisonPlanningDensity" in item && typeof item.comparisonPlanningDensity === "number"
966
+ ? item.comparisonPlanningDensity
967
+ : undefined,
968
+ comparisonPastness: "comparisonPastness" in item && typeof item.comparisonPastness === "number"
969
+ ? item.comparisonPastness
970
+ : undefined,
971
+ comparisonSideWitnessScore: "comparisonSideWitnessScore" in item && typeof item.comparisonSideWitnessScore === "number"
972
+ ? item.comparisonSideWitnessScore
973
+ : undefined,
974
+ }));
975
+ }
793
976
  }
794
977
  recoveryCandidates.push(
795
978
  ...reranked.ranked.map((item) => {
@@ -836,18 +1019,16 @@ export function buildContextEngineFactory(
836
1019
  }));
837
1020
  }
838
1021
  if (debugRecovery && rawUserRecoveryDebug.length > 0) {
839
- const selectedIDs = new Set(
840
- fittedRecovery
841
- .filter((item) => item.metadata.recovery_scope === "user_turns")
842
- .map((item: SearchResult) => item.id),
843
- );
844
1022
  rawUserRecoveryDebug = rawUserRecoveryDebug.map((item) => ({
845
1023
  ...item,
846
- selected: selectedIDs.has(item.id),
847
1024
  }));
848
1025
  }
849
1026
  }
850
1027
 
1028
+ if (debugRecovery && rawUserRecoveryDebug.length === 0 && temporalRecoveryPreviewDebug.length > 0) {
1029
+ rawUserRecoveryDebug = temporalRecoveryPreviewDebug;
1030
+ }
1031
+
851
1032
  const selected = [
852
1033
  ...hardItems,
853
1034
  ...tailBaseItems,
@@ -1189,6 +1370,7 @@ function clampFraction(value: number | undefined): number {
1189
1370
  async function ingestCanonicalMessage(params: {
1190
1371
  getRpc: RpcGetter;
1191
1372
  cfg: PluginConfig;
1373
+ logger: LoggerLike;
1192
1374
  recallCache: RecallCache<SearchResult>;
1193
1375
  clearElevatedCacheForSession: (sessionId: string) => void;
1194
1376
  sessionId: string;
@@ -1233,7 +1415,10 @@ async function ingestCanonicalMessage(params: {
1233
1415
  if (useSessionRecallProjection(params.cfg) && !params.skipProjectionRebuild) {
1234
1416
  await rebuildSessionRecallProjection(rpc, params.cfg, params.sessionId);
1235
1417
  }
1236
- } catch {
1418
+ } catch (error) {
1419
+ params.logger.error(
1420
+ `[libravdb] session ingest failed for ${params.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
1421
+ );
1237
1422
  return { ingested: false };
1238
1423
  }
1239
1424
 
@@ -1262,7 +1447,11 @@ async function ingestCanonicalMessage(params: {
1262
1447
  // User turns must be stored durably regardless of gating score so the agent can recall
1263
1448
  // friends, preferences, and context across sessions. Gating signals are still
1264
1449
  // recorded in metadata for observability, but do not gate the insert.
1265
- void rpc.call("insert_text", {
1450
+ //
1451
+ // IMPORTANT: This insert MUST be awaited. A previous fire-and-forget pattern
1452
+ // (void + catch) caused silent data loss when the daemon was slow/overloaded —
1453
+ // the user-level collection stayed empty, breaking cross-session recall entirely.
1454
+ await rpc.call("insert_text", {
1266
1455
  collection: `user:${durableNamespace}`,
1267
1456
  id: `${durableNamespace}:${turnId}`,
1268
1457
  text: normalized.content,
@@ -1286,9 +1475,14 @@ async function ingestCanonicalMessage(params: {
1286
1475
  gating_gconv: gating.gconv,
1287
1476
  gating_gtech: gating.gtech,
1288
1477
  },
1289
- }).catch(console.error);
1290
- } catch {
1291
- // Session storage already happened; skip durable promotion on gating failure.
1478
+ });
1479
+ } catch (userInsertError) {
1480
+ // Session storage already happened; log durable promotion failure visibly
1481
+ // so it does not silently vanish in production.
1482
+ params.logger.error(
1483
+ `[libravdb] durable user insert failed for ${durableNamespace}: ${userInsertError instanceof Error ? userInsertError.message : String(userInsertError)}`,
1484
+ );
1485
+ return { ingested: false };
1292
1486
  }
1293
1487
 
1294
1488
  return { ingested: true };
package/src/index.ts CHANGED
@@ -32,7 +32,7 @@ export default definePluginEntry({
32
32
 
33
33
  registerMemoryCli(api, runtime, cfg, api.logger ?? console);
34
34
  api.registerContextEngine("libravdb-memory", () =>
35
- buildContextEngineFactory(runtime.getRpc, cfg, recallCache),
35
+ buildContextEngineFactory(runtime.getRpc, cfg, recallCache, api.logger ?? console),
36
36
  );
37
37
  api.registerMemoryPromptSection(buildMemoryPromptSection(runtime.getRpc, cfg, recallCache));
38
38
  api.registerMemoryRuntime?.(buildMemoryRuntimeBridge(runtime.getRpc, cfg));
@@ -4,6 +4,7 @@ import type { LoggerLike, PluginConfig, SidecarHandle } from "./types.js";
4
4
 
5
5
  export type RpcGetter = () => Promise<RpcClient>;
6
6
  export const DEFAULT_RPC_TIMEOUT_MS = 30000;
7
+ export const STARTUP_HEALTH_TIMEOUT_MS = 2000;
7
8
 
8
9
  export interface LifecycleHint {
9
10
  hook: "before_reset" | "session_end";
@@ -43,7 +44,9 @@ export function createPluginRuntime(
43
44
  const rpc = new RpcClient(sidecar.socket, {
44
45
  timeoutMs: cfg.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
45
46
  });
46
- const health = await rpc.call<{ ok?: boolean; message?: string }>("health", {});
47
+ const health = await rpc.call<{ ok?: boolean; message?: string }>("health", {}, {
48
+ timeoutMs: STARTUP_HEALTH_TIMEOUT_MS,
49
+ });
47
50
  if (!health.ok) {
48
51
  try {
49
52
  await sidecar.shutdown();
package/src/rpc.ts CHANGED
@@ -18,20 +18,28 @@ export class RpcClient {
18
18
  socket.setEncoding("utf8");
19
19
  socket.on("data", (chunk) => this.handleData(chunk));
20
20
  socket.on("close", () => this.rejectAll(new Error("Socket closed")));
21
+ socket.on("error", (error) => this.rejectAll(error));
21
22
  }
22
23
 
23
- async call<T>(method: string, _params: unknown): Promise<T> {
24
+ async call<T>(method: string, _params: unknown, callOptions: Partial<RpcCallOptions> = {}): Promise<T> {
24
25
  return await new Promise<T>((resolve, reject) => {
25
26
  const id = ++this.seq;
27
+ const timeoutMs = callOptions.timeoutMs ?? this.options.timeoutMs;
26
28
  const timer = setTimeout(() => {
27
29
  this.pending.delete(id);
28
- reject(new Error(`RPC timeout: ${method} (${this.options.timeoutMs}ms)`));
29
- }, this.options.timeoutMs);
30
+ reject(new Error(`RPC timeout: ${method} (${timeoutMs}ms)`));
31
+ }, timeoutMs);
30
32
 
31
33
  this.pending.set(id, { resolve, reject, timer });
32
- this.socket.write(
33
- `${JSON.stringify({ jsonrpc: "2.0", id, method, params: _params })}\n`,
34
- );
34
+ try {
35
+ this.socket.write(
36
+ `${JSON.stringify({ jsonrpc: "2.0", id, method, params: _params })}\n`,
37
+ );
38
+ } catch (error) {
39
+ clearTimeout(timer);
40
+ this.pending.delete(id);
41
+ reject(error instanceof Error ? error : new Error(String(error)));
42
+ }
35
43
  });
36
44
  }
37
45
 
package/src/sidecar.ts CHANGED
@@ -9,6 +9,10 @@ type CloseHandler = () => void;
9
9
  type DataHandler = (chunk: string) => void;
10
10
  type ErrorHandler = (error: Error) => void;
11
11
 
12
+ const STARTUP_CONNECT_MAX_RETRIES = 5;
13
+ const STARTUP_CONNECT_BASE_DELAY_MS = 100;
14
+ const STARTUP_CONNECT_MAX_TOTAL_WAIT_MS = 2000;
15
+
12
16
  export interface SidecarRuntime {
13
17
  resolveEndpoint(cfg: PluginConfig): string | Promise<string>;
14
18
  createSocket(endpoint: string): SidecarSocket;
@@ -18,6 +22,7 @@ export interface SidecarRuntime {
18
22
  class PlaceholderSocket implements SidecarSocket {
19
23
  private readonly onData = new Set<DataHandler>();
20
24
  private readonly onClose = new Set<CloseHandler>();
25
+ private readonly onError = new Set<ErrorHandler>();
21
26
  private readonly connectOnce = new Set<CloseHandler>();
22
27
  private readonly errorOnce = new Set<ErrorHandler>();
23
28
 
@@ -32,11 +37,15 @@ class PlaceholderSocket implements SidecarSocket {
32
37
 
33
38
  setEncoding(_encoding: string): void {}
34
39
 
35
- on(event: "data" | "close", handler: DataHandler | CloseHandler): void {
40
+ on(event: "data" | "close" | "error", handler: DataHandler | CloseHandler | ErrorHandler): void {
36
41
  if (event === "data") {
37
42
  this.onData.add(handler as DataHandler);
38
43
  return;
39
44
  }
45
+ if (event === "error") {
46
+ this.onError.add(handler as ErrorHandler);
47
+ return;
48
+ }
40
49
  this.onClose.add(handler as CloseHandler);
41
50
  }
42
51
 
@@ -61,10 +70,7 @@ class PlaceholderSocket implements SidecarSocket {
61
70
  }
62
71
  } catch (error) {
63
72
  const err = error instanceof Error ? error : new Error(String(error));
64
- for (const handler of this.errorOnce) {
65
- handler(err);
66
- }
67
- this.errorOnce.clear();
73
+ this.emitError(err);
68
74
  }
69
75
  }
70
76
 
@@ -73,11 +79,22 @@ class PlaceholderSocket implements SidecarSocket {
73
79
  handler();
74
80
  }
75
81
  }
82
+
83
+ private emitError(error: Error): void {
84
+ for (const handler of this.onError) {
85
+ handler(error);
86
+ }
87
+ for (const handler of this.errorOnce) {
88
+ handler(error);
89
+ }
90
+ this.errorOnce.clear();
91
+ }
76
92
  }
77
93
 
78
94
  class SupervisorSocket implements SidecarSocket {
79
95
  private readonly onData = new Set<DataHandler>();
80
96
  private readonly onClose = new Set<CloseHandler>();
97
+ private readonly onError = new Set<ErrorHandler>();
81
98
  private readonly connectOnce = new Set<CloseHandler>();
82
99
  private readonly errorOnce = new Set<ErrorHandler>();
83
100
  private current?: SidecarSocket;
@@ -102,10 +119,24 @@ class SupervisorSocket implements SidecarSocket {
102
119
  if (generation !== this.generation) {
103
120
  return;
104
121
  }
122
+ this.current = undefined;
105
123
  for (const handler of this.onClose) {
106
124
  handler();
107
125
  }
108
126
  });
127
+ socket.on("error", (error) => {
128
+ if (generation !== this.generation) {
129
+ return;
130
+ }
131
+ this.current = undefined;
132
+ for (const handler of this.onError) {
133
+ handler(error);
134
+ }
135
+ for (const handler of this.errorOnce) {
136
+ handler(error);
137
+ }
138
+ this.errorOnce.clear();
139
+ });
109
140
 
110
141
  for (const handler of this.connectOnce) {
111
142
  handler();
@@ -118,11 +149,15 @@ class SupervisorSocket implements SidecarSocket {
118
149
  this.current?.setEncoding(encoding);
119
150
  }
120
151
 
121
- on(event: "data" | "close", handler: DataHandler | CloseHandler): void {
152
+ on(event: "data" | "close" | "error", handler: DataHandler | CloseHandler | ErrorHandler): void {
122
153
  if (event === "data") {
123
154
  this.onData.add(handler as DataHandler);
124
155
  return;
125
156
  }
157
+ if (event === "error") {
158
+ this.onError.add(handler as ErrorHandler);
159
+ return;
160
+ }
126
161
  this.onClose.add(handler as CloseHandler);
127
162
  }
128
163
 
@@ -154,6 +189,7 @@ class SidecarSupervisor implements SidecarHandle {
154
189
  private retries = 0;
155
190
  private degraded = false;
156
191
  private shuttingDown = false;
192
+ private reconnectScheduled = false;
157
193
  public socket: SidecarSocket;
158
194
 
159
195
  constructor(
@@ -166,7 +202,8 @@ class SidecarSupervisor implements SidecarHandle {
166
202
 
167
203
  async start(): Promise<SidecarSocket> {
168
204
  const endpoint = await this.runtime.resolveEndpoint(this.cfg);
169
- const socket = await this.connectEndpoint(endpoint);
205
+ const socket = await this.connectEndpointWithRetry(endpoint);
206
+ this.reconnectScheduled = false;
170
207
  if (this.socket instanceof SupervisorSocket) {
171
208
  this.socket.bind(socket);
172
209
  } else {
@@ -184,21 +221,48 @@ class SidecarSupervisor implements SidecarHandle {
184
221
  this.socket.destroy();
185
222
  }
186
223
 
187
- private async connectEndpoint(endpoint: string): Promise<SidecarSocket> {
188
- const socket = this.runtime.createSocket(endpoint);
189
- socket.on("close", () => {
190
- void this.handleExit(1);
191
- });
192
-
224
+ private async connectEndpointWithRetry(endpoint: string): Promise<SidecarSocket> {
193
225
  if (isTcpEndpoint(endpoint)) {
194
226
  this.logger.info?.(`[libravdb] using TCP endpoint ${endpoint}`);
195
227
  } else {
196
228
  this.logger.info?.(`[libravdb] using Unix socket ${endpoint}`);
197
229
  }
198
230
 
231
+ let waitedMs = 0;
232
+ for (let attempt = 0; ; attempt += 1) {
233
+ try {
234
+ return await this.connectEndpoint(endpoint);
235
+ } catch (error) {
236
+ if (!isStartupConnectRetryableError(error) || attempt >= STARTUP_CONNECT_MAX_RETRIES - 1) {
237
+ throw error;
238
+ }
239
+
240
+ const delayMs = computeStartupConnectRetryDelay(attempt, waitedMs);
241
+ if (delayMs <= 0) {
242
+ throw error;
243
+ }
244
+ waitedMs += delayMs;
245
+ this.logger.info?.(
246
+ `[libravdb] Daemon not ready, retrying connection (attempt ${attempt + 1}/${STARTUP_CONNECT_MAX_RETRIES})...`,
247
+ );
248
+ await sleep(delayMs);
249
+ }
250
+ }
251
+ }
252
+
253
+ private async connectEndpoint(endpoint: string): Promise<SidecarSocket> {
254
+ const socket = this.runtime.createSocket(endpoint);
199
255
  return await new Promise<SidecarSocket>((resolve, reject) => {
200
- socket.once("connect", () => resolve(socket));
201
- socket.once("error", (error) => reject(formatConnectionError(endpoint, error)));
256
+ socket.once("connect", () => {
257
+ socket.on("close", () => {
258
+ void this.handleExit(1);
259
+ });
260
+ resolve(socket);
261
+ });
262
+ socket.once("error", (error) => {
263
+ socket.destroy();
264
+ reject(formatConnectionError(endpoint, error));
265
+ });
202
266
  });
203
267
  }
204
268
 
@@ -209,6 +273,9 @@ class SidecarSupervisor implements SidecarHandle {
209
273
  if (code === 0) {
210
274
  return;
211
275
  }
276
+ if (this.reconnectScheduled) {
277
+ return;
278
+ }
212
279
 
213
280
  const maxRetries = this.cfg.maxRetries ?? 3;
214
281
  if (this.retries >= maxRetries) {
@@ -219,8 +286,10 @@ class SidecarSupervisor implements SidecarHandle {
219
286
 
220
287
  const backoffMs = computeBackoffMs(this.retries);
221
288
  this.retries += 1;
289
+ this.reconnectScheduled = true;
222
290
  this.runtime.scheduleRestart(backoffMs, () => {
223
291
  void this.start().catch((error) => {
292
+ this.reconnectScheduled = false;
224
293
  const message = error instanceof Error ? error.message : String(error);
225
294
  this.logger.error(`[libravdb] sidecar reconnect failed: ${message}`);
226
295
  });
@@ -242,6 +311,17 @@ export function computeBackoffMs(retries: number): number {
242
311
  return Math.min(500 * Math.pow(2, retries), 16000);
243
312
  }
244
313
 
314
+ export function computeStartupConnectRetryDelay(attempt: number, waitedMs = 0): number {
315
+ if (attempt < 0) {
316
+ return 0;
317
+ }
318
+ const remainingMs = STARTUP_CONNECT_MAX_TOTAL_WAIT_MS - waitedMs;
319
+ if (remainingMs <= 0) {
320
+ return 0;
321
+ }
322
+ return Math.min(STARTUP_CONNECT_BASE_DELAY_MS * Math.pow(2, attempt), remainingMs);
323
+ }
324
+
245
325
  export function isTcpEndpoint(endpoint: string): boolean {
246
326
  return endpoint.startsWith("tcp:");
247
327
  }
@@ -423,16 +503,29 @@ function createDefaultRuntime(): SidecarRuntime {
423
503
  };
424
504
  }
425
505
 
506
+ function isStartupConnectRetryableError(error: unknown): boolean {
507
+ const code = typeof (error as NodeJS.ErrnoException | undefined)?.code === "string"
508
+ ? (error as NodeJS.ErrnoException).code
509
+ : "";
510
+ return code === "ENOENT" || code === "ECONNREFUSED";
511
+ }
512
+
426
513
  function formatConnectionError(endpoint: string, error: Error): Error {
427
514
  const code = typeof (error as NodeJS.ErrnoException).code === "string"
428
515
  ? (error as NodeJS.ErrnoException).code
429
516
  : "";
517
+ const annotated = error instanceof Error ? error : new Error(String(error));
518
+ if (code) {
519
+ (annotated as NodeJS.ErrnoException).code = code;
520
+ }
430
521
  if (code === "ENOENT" || code === "ECONNREFUSED") {
431
- return new Error(
522
+ const unavailable = new Error(
432
523
  `LibraVDB daemon unavailable at ${describeEndpoint(endpoint)}. ${daemonProvisioningHint()} Or set sidecarPath to a running daemon endpoint.`,
433
524
  );
525
+ (unavailable as NodeJS.ErrnoException).code = code;
526
+ return unavailable;
434
527
  }
435
- return error;
528
+ return annotated;
436
529
  }
437
530
 
438
531
  function describeEndpoint(endpoint: string): string {
@@ -448,6 +541,10 @@ function isConfiguredEndpoint(value?: string): boolean {
448
541
 
449
542
  export { PlaceholderSocket };
450
543
 
544
+ function sleep(delayMs: number): Promise<void> {
545
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
546
+ }
547
+
451
548
  export async function probeSidecarEndpoint(cfg: PluginConfig): Promise<string | null> {
452
549
  const endpoint = resolveConfiguredEndpoint(cfg);
453
550
  try {
package/src/temporal.ts CHANGED
@@ -279,6 +279,13 @@ export function rankTemporalRecoveryCandidates(
279
279
  const temporalQuery = detectTemporalQuerySignal(opts.queryText);
280
280
  const slots = extractTemporalSlots(opts.queryText);
281
281
  const isComparisonQuery = temporalQuery.matchedPatterns.includes("first or earlier");
282
+ // Duration-interval queries ("how many days ... after", "how long ... since") require explicit
283
+ // date anchors to be answerable. The soft-blend scorer can otherwise let an anchor-free turn
284
+ // outrank anchor-bearing ones when the semantic signal is still high.
285
+ const isDurationIntervalQuery = !isComparisonQuery && (
286
+ temporalQuery.matchedPatterns.includes("how many days") ||
287
+ temporalQuery.matchedPatterns.includes("how long")
288
+ );
282
289
  const effectiveSlots = isComparisonQuery ? filterComparisonSlots(slots) : slots;
283
290
  const comparisonSlots = isComparisonQuery ? deriveComparisonSideSlots(effectiveSlots) : [];
284
291
  const recencyLambda = Math.max(0, opts.recencyLambda ?? 0.00001);
@@ -299,6 +306,16 @@ export function rankTemporalRecoveryCandidates(
299
306
  comparisonProfile.rawCandidateCount = items.length;
300
307
  }
301
308
 
309
+ // Pre-scan anchor density only for duration-interval queries so the gate below can verify at
310
+ // least one anchor-bearing candidate exists before zeroing anchor-free ones. Cached, so the
311
+ // re-check inside the map is cheap.
312
+ const anyAnchorBearing = isDurationIntervalQuery && items.some((item) =>
313
+ getTemporalAnchorDensity(
314
+ `${typeof item.metadata.collection === "string" ? item.metadata.collection : "unknown"}::${item.id}`,
315
+ item.text,
316
+ ) > 0,
317
+ );
318
+
302
319
  const decorateStart = comparisonProfile ? process.hrtime.bigint() : 0n;
303
320
  const decorated = items.map((item) => {
304
321
  const semanticScore = clamp01(typeof item.finalScore === "number" ? item.finalScore : item.score ?? 0);
@@ -331,6 +348,10 @@ export function rankTemporalRecoveryCandidates(
331
348
  comparisonSideWitnessScore,
332
349
  temporalQueryActive: temporalQuery.active,
333
350
  })
351
+ // Duration-interval gate: if we have at least one anchor-bearing candidate, anchor-free
352
+ // candidates cannot answer the date question and should stay below the greedy threshold.
353
+ : isDurationIntervalQuery && anyAnchorBearing && temporalAnchorDensity === 0
354
+ ? 0
334
355
  : (0.40 * semanticScore) +
335
356
  (0.25 * recencyScore) +
336
357
  (0.20 * temporalAnchorDensity) +
package/src/types.ts CHANGED
@@ -152,6 +152,7 @@ export interface SidecarSocket {
152
152
  setEncoding(encoding: string): void;
153
153
  on(event: "data", handler: (chunk: string) => void): void;
154
154
  on(event: "close", handler: () => void): void;
155
+ on(event: "error", handler: (error: Error) => void): void;
155
156
  once(event: "connect", handler: () => void): void;
156
157
  once(event: "error", handler: (error: Error) => void): void;
157
158
  write(chunk: string): void;