@equationalapplications/core-llm-wiki 3.0.0 → 3.1.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.
package/dist/index.js CHANGED
@@ -560,6 +560,11 @@ var _WikiMemory = class _WikiMemory {
560
560
  `UPDATE ${this.prefix}entries SET embedding_blob = ?, embedding = NULL WHERE id = ?`,
561
561
  [blob, fact.id]
562
562
  );
563
+ try {
564
+ await this._notifyEmbeddingPersisted(fact.entity_id, fact.id, float32Vector);
565
+ } catch (hookErr) {
566
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed for ${fact.id}:`, hookErr);
567
+ }
563
568
  return true;
564
569
  } catch (err) {
565
570
  console.warn(`[WikiMemory] embedFact failed for ${fact.id}:`, err);
@@ -575,6 +580,9 @@ var _WikiMemory = class _WikiMemory {
575
580
  _warnCrossEntityCollision(type, id, existingEntityId, targetEntityId) {
576
581
  console.warn(`[WikiMemory] importDump: ${type} id "${id}" already belongs to entity "${existingEntityId}"; skipping for entity "${targetEntityId}"`);
577
582
  }
583
+ async _notifyEmbeddingPersisted(entityId, factId, vector) {
584
+ await this.options.vectorRanker?.onEmbeddingPersisted?.({ entityId, factId, vector });
585
+ }
578
586
  async setup() {
579
587
  const entriesExistedBeforeSetup = await this.db.getFirstAsync(
580
588
  `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
@@ -757,8 +765,15 @@ var _WikiMemory = class _WikiMemory {
757
765
  let deletedEntries = 0;
758
766
  let deletedTasks = 0;
759
767
  let deletedEvents = 0;
768
+ const deletedEntryIds = [];
760
769
  if (retainSoftDeletedFor !== null) {
761
770
  const cutoff = now - retainSoftDeletedFor * 864e5;
771
+ const entriesToDelete = await this.db.getAllAsync(
772
+ `SELECT id FROM ${this.prefix}entries
773
+ WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at < ?`,
774
+ [entityId, cutoff]
775
+ );
776
+ deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
762
777
  const entryResult = await this.db.runAsync(
763
778
  `DELETE FROM ${this.prefix}entries
764
779
  WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at < ?`,
@@ -787,6 +802,14 @@ var _WikiMemory = class _WikiMemory {
787
802
  }
788
803
  await this.rebuildMiniSearchIndex(entityId);
789
804
  this.vectorCache.delete(entityId);
805
+ const uniqueDeletedIds = Array.from(new Set(deletedEntryIds));
806
+ for (const factId of uniqueDeletedIds) {
807
+ try {
808
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
809
+ } catch (hookErr) {
810
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during prune for ${factId}:`, hookErr);
811
+ }
812
+ }
790
813
  return { entries: deletedEntries, tasks: deletedTasks, events: deletedEvents };
791
814
  } finally {
792
815
  this.activeMaintenanceJobs.delete(pruneKey);
@@ -807,6 +830,10 @@ var _WikiMemory = class _WikiMemory {
807
830
  if (maxResults === 0) ; else if (trimmedQuery) {
808
831
  let usedEmbed = false;
809
832
  if (!skipEmbed && embedFn) {
833
+ let rankerShouldRethrow = false;
834
+ let pendingRankerFallbackError;
835
+ let usedKeywordFallback = false;
836
+ let scoredAlreadySortedAndLimited = false;
810
837
  try {
811
838
  const queryVec = await embedFn(trimmedQuery);
812
839
  if (queryVec.length === 0 || !queryVec.every((v) => typeof v === "number" && isFinite(v))) {
@@ -838,6 +865,7 @@ var _WikiMemory = class _WikiMemory {
838
865
  `Some facts have embeddings that do not match the current model dimension. Call runReembed() to rebuild all embeddings consistently.`
839
866
  );
840
867
  }
868
+ const useRanker = Boolean(this.options.vectorRanker);
841
869
  let candidateRows;
842
870
  let populateCache = true;
843
871
  let miniSearchScores;
@@ -856,15 +884,30 @@ var _WikiMemory = class _WikiMemory {
856
884
  } else {
857
885
  const topKIds = topKResults.map((r) => r.id);
858
886
  const inClauseChunkSize = 500;
859
- candidateRows = [];
860
- for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
861
- const idChunk = topKIds.slice(i, i + inClauseChunkSize);
862
- const placeholders = idChunk.map(() => "?").join(",");
863
- const chunkRows = await this.db.getAllAsync(
864
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
865
- idChunk
866
- );
867
- candidateRows.push(...chunkRows);
887
+ if (useRanker) {
888
+ const rows = [];
889
+ for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
890
+ const idChunk = topKIds.slice(i, i + inClauseChunkSize);
891
+ const placeholders = idChunk.map(() => "?").join(",");
892
+ const chunkRows = await this.db.getAllAsync(
893
+ `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
894
+ idChunk
895
+ );
896
+ rows.push(...chunkRows);
897
+ }
898
+ candidateRows = rows;
899
+ } else {
900
+ const rows = [];
901
+ for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
902
+ const idChunk = topKIds.slice(i, i + inClauseChunkSize);
903
+ const placeholders = idChunk.map(() => "?").join(",");
904
+ const chunkRows = await this.db.getAllAsync(
905
+ `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
906
+ idChunk
907
+ );
908
+ rows.push(...chunkRows);
909
+ }
910
+ candidateRows = rows;
868
911
  }
869
912
  if (weight !== void 0 && weight < 1) {
870
913
  const maxMsScore = Math.max(1, topKResults[0]?.score ?? 1);
@@ -873,10 +916,17 @@ var _WikiMemory = class _WikiMemory {
873
916
  }
874
917
  }
875
918
  } else {
876
- candidateRows = await this.db.getAllAsync(
877
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
878
- [entityId]
879
- );
919
+ if (useRanker) {
920
+ candidateRows = await this.db.getAllAsync(
921
+ `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
922
+ [entityId]
923
+ );
924
+ } else {
925
+ candidateRows = await this.db.getAllAsync(
926
+ `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
927
+ [entityId]
928
+ );
929
+ }
880
930
  if (weight !== void 0 && weight < 1) {
881
931
  const msResults = this.miniSearch.search(trimmedQuery, {
882
932
  filter: (r) => r.entity_id === entityId,
@@ -889,76 +939,263 @@ var _WikiMemory = class _WikiMemory {
889
939
  if (candidateRows === null) {
890
940
  usedEmbed = true;
891
941
  } else {
892
- let entityCache = this.vectorCache.get(entityId);
893
- const tooLarge = populateCache && candidateRows.length > _WikiMemory.MAX_VECTOR_CACHE_FACTS_PER_ENTITY;
894
- if (tooLarge && entityCache) {
895
- this.vectorCache.delete(entityId);
896
- entityCache = void 0;
897
- }
898
- const canCache = populateCache && !tooLarge;
899
- if (canCache && !entityCache) {
900
- entityCache = /* @__PURE__ */ new Map();
901
- }
902
- const scored = candidateRows.map((row) => {
903
- let vector = entityCache?.get(row.id) ?? parseEmbedding(row.embedding_blob, row.embedding);
904
- if (vector && canCache && entityCache && !entityCache.has(row.id)) {
905
- entityCache.set(row.id, vector);
906
- }
907
- let score = 0;
908
- if (vector && vector.length === queryVec.length) {
909
- const cosSim = cosineSimilarity(queryVec, vector);
910
- if (weight !== void 0) {
911
- const kwScore = miniSearchScores?.get(row.id) ?? 0;
912
- score = weight * Math.max(0, cosSim) + (1 - weight) * kwScore;
942
+ let scored;
943
+ if (useRanker) {
944
+ const candidateIds = effectivePreFilterLimit !== void 0 ? candidateRows.map((r) => r.id) : void 0;
945
+ try {
946
+ const oversampledLimit = Math.max(maxResults * 2, maxResults + 50);
947
+ scored = await this._rankWithVectorRanker({
948
+ entityId,
949
+ queryVec,
950
+ candidateIds,
951
+ weight,
952
+ miniSearchScores,
953
+ limit: oversampledLimit
954
+ });
955
+ if (scored.length > 0) {
956
+ const scoredIds2 = new Set(scored.map((s) => s.id));
957
+ const metaMap = /* @__PURE__ */ new Map();
958
+ for (const r of candidateRows) {
959
+ if (scoredIds2.has(r.id)) {
960
+ metaMap.set(r.id, { updated_at: r.updated_at, access_count: r.access_count });
961
+ }
962
+ }
963
+ scored = scored.map((s) => {
964
+ const meta = metaMap.get(s.id);
965
+ return { ...s, updated_at: meta?.updated_at ?? null, access_count: meta?.access_count ?? null };
966
+ });
967
+ }
968
+ const scoredIds = new Set(scored.map((s) => s.id));
969
+ const isHybrid = weight !== void 0 && weight < 1;
970
+ const maxBackfill = isHybrid ? maxResults : Math.max(0, maxResults - scored.length);
971
+ if (maxBackfill > 0) {
972
+ if (isHybrid) {
973
+ const topK = [];
974
+ for (const row of candidateRows) {
975
+ if (scoredIds.has(row.id)) continue;
976
+ const kwScore = miniSearchScores?.get(row.id) ?? 0;
977
+ const candidate = { row, kwScore };
978
+ if (topK.length < maxBackfill) {
979
+ let insertIdx = topK.length;
980
+ for (let i = 0; i < topK.length; i++) {
981
+ const cmp = this._compareScoredRows(
982
+ {
983
+ id: candidate.row.id,
984
+ score: candidate.kwScore,
985
+ updated_at: candidate.row.updated_at,
986
+ access_count: candidate.row.access_count
987
+ },
988
+ {
989
+ id: topK[i].row.id,
990
+ score: topK[i].kwScore,
991
+ updated_at: topK[i].row.updated_at,
992
+ access_count: topK[i].row.access_count
993
+ }
994
+ );
995
+ if (cmp < 0) {
996
+ insertIdx = i;
997
+ break;
998
+ }
999
+ }
1000
+ topK.splice(insertIdx, 0, candidate);
1001
+ } else {
1002
+ const cmpWorst = this._compareScoredRows(
1003
+ {
1004
+ id: candidate.row.id,
1005
+ score: candidate.kwScore,
1006
+ updated_at: candidate.row.updated_at,
1007
+ access_count: candidate.row.access_count
1008
+ },
1009
+ {
1010
+ id: topK[maxBackfill - 1].row.id,
1011
+ score: topK[maxBackfill - 1].kwScore,
1012
+ updated_at: topK[maxBackfill - 1].row.updated_at,
1013
+ access_count: topK[maxBackfill - 1].row.access_count
1014
+ }
1015
+ );
1016
+ if (cmpWorst < 0) {
1017
+ let insertIdx = maxBackfill - 1;
1018
+ for (let i = 0; i < topK.length; i++) {
1019
+ const cmp = this._compareScoredRows(
1020
+ {
1021
+ id: candidate.row.id,
1022
+ score: candidate.kwScore,
1023
+ updated_at: candidate.row.updated_at,
1024
+ access_count: candidate.row.access_count
1025
+ },
1026
+ {
1027
+ id: topK[i].row.id,
1028
+ score: topK[i].kwScore,
1029
+ updated_at: topK[i].row.updated_at,
1030
+ access_count: topK[i].row.access_count
1031
+ }
1032
+ );
1033
+ if (cmp < 0) {
1034
+ insertIdx = i;
1035
+ break;
1036
+ }
1037
+ }
1038
+ topK.splice(insertIdx, 0, candidate);
1039
+ topK.pop();
1040
+ }
1041
+ }
1042
+ }
1043
+ for (const { row, kwScore } of topK) {
1044
+ scored.push({
1045
+ id: row.id,
1046
+ score: (1 - weight) * kwScore,
1047
+ updated_at: row.updated_at,
1048
+ access_count: row.access_count
1049
+ });
1050
+ }
1051
+ } else {
1052
+ const omitted = [];
1053
+ for (const row of candidateRows) {
1054
+ if (scoredIds.has(row.id)) continue;
1055
+ omitted.push({ id: row.id, score: -2, updated_at: row.updated_at, access_count: row.access_count });
1056
+ }
1057
+ if (omitted.length > 0) {
1058
+ this._tieBreakSort(omitted);
1059
+ scored.push(...omitted.slice(0, maxBackfill));
1060
+ }
1061
+ }
1062
+ }
1063
+ } catch (rankerErr) {
1064
+ const rankerError = rankerErr instanceof Error ? rankerErr : new Error(String(rankerErr));
1065
+ const policy = this.options.vectorRankerFallback ?? "js-cosine";
1066
+ this.options.onVectorRankerFallback?.({ error: rankerError, policy });
1067
+ if (policy === "throw") {
1068
+ rankerShouldRethrow = true;
1069
+ throw rankerError;
1070
+ } else if (policy === "js-cosine") {
1071
+ let fallbackRows = candidateRows;
1072
+ if (fallbackRows && fallbackRows.length > 0 && !("embedding_blob" in fallbackRows[0])) {
1073
+ const rowIds = fallbackRows.map((r) => r.id);
1074
+ const embeddingsMap = /* @__PURE__ */ new Map();
1075
+ const chunkSize = 500;
1076
+ for (let i = 0; i < rowIds.length; i += chunkSize) {
1077
+ const idChunk = rowIds.slice(i, i + chunkSize);
1078
+ const placeholders = idChunk.map(() => "?").join(",");
1079
+ const embeddingRows = await this.db.getAllAsync(
1080
+ `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1081
+ [...idChunk, entityId]
1082
+ );
1083
+ for (const row of embeddingRows) {
1084
+ embeddingsMap.set(row.id, { embedding_blob: row.embedding_blob, embedding: row.embedding });
1085
+ }
1086
+ }
1087
+ fallbackRows = fallbackRows.map((r) => ({
1088
+ ...r,
1089
+ embedding_blob: embeddingsMap.get(r.id)?.embedding_blob ?? null,
1090
+ embedding: embeddingsMap.get(r.id)?.embedding ?? null
1091
+ }));
1092
+ }
1093
+ scored = await this._rankWithJsCosine({
1094
+ entityId,
1095
+ queryVec,
1096
+ candidateRows: fallbackRows,
1097
+ weight,
1098
+ miniSearchScores,
1099
+ populateCache,
1100
+ limit: maxResults
1101
+ });
1102
+ scoredAlreadySortedAndLimited = true;
1103
+ } else if (policy === "keyword") {
1104
+ const msResults = this.miniSearch.search(trimmedQuery, {
1105
+ filter: (r) => r.entity_id === entityId,
1106
+ combineWith: "OR"
1107
+ });
1108
+ const topResults = msResults.slice(0, maxResults);
1109
+ const resultIds = new Set(topResults.map((r) => r.id));
1110
+ const candidateMap = /* @__PURE__ */ new Map();
1111
+ for (const r of candidateRows) {
1112
+ if (resultIds.has(r.id)) {
1113
+ candidateMap.set(r.id, { updated_at: r.updated_at, access_count: r.access_count });
1114
+ }
1115
+ }
1116
+ scored = topResults.map((r) => {
1117
+ const meta = candidateMap.get(r.id);
1118
+ return {
1119
+ id: r.id,
1120
+ score: r.score ?? 0,
1121
+ access_count: meta?.access_count ?? null,
1122
+ updated_at: meta?.updated_at ?? null
1123
+ };
1124
+ });
1125
+ usedKeywordFallback = true;
913
1126
  } else {
914
- score = cosSim;
1127
+ scored = [];
915
1128
  }
916
- } else if (weight !== void 0 && weight < 1) {
917
- const kwScore = miniSearchScores?.get(row.id) ?? 0;
918
- score = (1 - weight) * kwScore;
919
- } else {
920
- score = -2;
921
- }
922
- return { row, score };
923
- });
924
- if (canCache && entityCache && entityCache.size > 0) {
925
- if (!this.vectorCache.has(entityId)) {
926
- if (this.vectorCache.size >= _WikiMemory.MAX_VECTOR_CACHE_ENTITIES) {
927
- const oldestKey = this.vectorCache.keys().next().value;
928
- if (oldestKey !== void 0) this.vectorCache.delete(oldestKey);
1129
+ if (this.options.propagateRankerFailureToRetrievalFallback) {
1130
+ const mirrored = new Error("Vector ranker failed, falling back");
1131
+ mirrored.cause = rankerError;
1132
+ pendingRankerFallbackError = mirrored;
929
1133
  }
930
- this.vectorCache.set(entityId, entityCache);
931
1134
  }
1135
+ } else {
1136
+ scored = await this._rankWithJsCosine({
1137
+ entityId,
1138
+ queryVec,
1139
+ candidateRows,
1140
+ weight,
1141
+ miniSearchScores,
1142
+ populateCache,
1143
+ limit: maxResults
1144
+ });
1145
+ scoredAlreadySortedAndLimited = true;
932
1146
  }
933
- scored.sort((a, b) => {
934
- const scoreDiff = b.score - a.score;
935
- if (scoreDiff !== 0) return scoreDiff;
936
- const accessCountDiff = (b.row.access_count ?? 0) - (a.row.access_count ?? 0);
937
- if (accessCountDiff !== 0) return accessCountDiff;
938
- const updatedAtDiff = (b.row.updated_at ?? 0) - (a.row.updated_at ?? 0);
939
- if (updatedAtDiff !== 0) return updatedAtDiff;
940
- return a.row.id.localeCompare(b.row.id);
941
- });
942
- const topIds = scored.slice(0, maxResults).map((s) => s.row.id);
943
- if (topIds.length > 0) {
944
- const fullRows = [];
945
- const phase2ChunkSize = 500;
946
- for (let i = 0; i < topIds.length; i += phase2ChunkSize) {
947
- const idChunk = topIds.slice(i, i + phase2ChunkSize);
948
- const placeholders = idChunk.map(() => "?").join(",");
949
- const chunkRows = await this.db.getAllAsync(
950
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
951
- idChunk
952
- );
953
- fullRows.push(...chunkRows);
1147
+ if (scored.length > 0) {
1148
+ if (!usedKeywordFallback && !scoredAlreadySortedAndLimited) {
1149
+ this._tieBreakSort(scored);
1150
+ }
1151
+ const topIds = (scoredAlreadySortedAndLimited ? scored : scored.slice(0, maxResults)).map((s) => s.id);
1152
+ if (topIds.length > 0) {
1153
+ const fullRows = [];
1154
+ const phase2ChunkSize = 500;
1155
+ for (let i = 0; i < topIds.length; i += phase2ChunkSize) {
1156
+ const idChunk = topIds.slice(i, i + phase2ChunkSize);
1157
+ const placeholders = idChunk.map(() => "?").join(",");
1158
+ const chunkRows = await this.db.getAllAsync(
1159
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1160
+ [...idChunk, entityId]
1161
+ );
1162
+ fullRows.push(...chunkRows);
1163
+ }
1164
+ const byId = new Map(fullRows.map((r) => [r.id, r]));
1165
+ facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1166
+ if (facts.length < topIds.length) {
1167
+ const missingIds = topIds.filter((id) => !byId.has(id));
1168
+ const missingCount = missingIds.length;
1169
+ const sample = missingIds.slice(0, 5);
1170
+ const sampleSuffix = sample.length > 0 ? ` Missing ID sample: ${sample.join(", ")}${missingIds.length > sample.length ? ", ..." : ""}.` : "";
1171
+ const error = new Error(
1172
+ `Phase 2 fact hydration returned ${missingCount} fewer row(s) than ranked IDs for entity ${entityId}. Rows may have been concurrently soft-deleted or filtered by deleted_at during hydration, or vector ranker output may include IDs that do not exist for this entity.` + sampleSuffix
1173
+ );
1174
+ this.options.onRetrievalFallback?.(error);
1175
+ }
1176
+ }
1177
+ if (pendingRankerFallbackError) {
1178
+ this.options.onRetrievalFallback?.(pendingRankerFallbackError);
1179
+ pendingRankerFallbackError = void 0;
1180
+ }
1181
+ usedEmbed = true;
1182
+ } else {
1183
+ if (pendingRankerFallbackError) {
1184
+ this.options.onRetrievalFallback?.(pendingRankerFallbackError);
1185
+ pendingRankerFallbackError = void 0;
954
1186
  }
955
- const byId = new Map(fullRows.map((r) => [r.id, r]));
956
- facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1187
+ usedEmbed = true;
957
1188
  }
958
- usedEmbed = true;
959
1189
  }
960
1190
  } catch (err) {
961
1191
  const error = err instanceof Error ? err : new Error(String(err));
1192
+ if (rankerShouldRethrow) {
1193
+ throw error;
1194
+ }
1195
+ if (pendingRankerFallbackError) {
1196
+ error.cause = pendingRankerFallbackError;
1197
+ pendingRankerFallbackError = void 0;
1198
+ }
962
1199
  this.options.onRetrievalFallback?.(error);
963
1200
  }
964
1201
  }
@@ -975,8 +1212,8 @@ var _WikiMemory = class _WikiMemory {
975
1212
  const idChunk = topIds.slice(i, i + kwChunkSize);
976
1213
  const placeholders = idChunk.map(() => "?").join(",");
977
1214
  const chunkRows = await this.db.getAllAsync(
978
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
979
- idChunk
1215
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1216
+ [...idChunk, entityId]
980
1217
  );
981
1218
  kwRows.push(...chunkRows);
982
1219
  }
@@ -1032,6 +1269,113 @@ var _WikiMemory = class _WikiMemory {
1032
1269
  });
1033
1270
  return { facts: parsedFacts, tasks, events: events.reverse() };
1034
1271
  }
1272
+ /**
1273
+ * Stable tie-break sort: score desc → access_count desc → updated_at desc → id asc.
1274
+ */
1275
+ _tieBreakSort(items) {
1276
+ items.sort((a, b) => this._compareScoredRows(a, b));
1277
+ }
1278
+ /**
1279
+ * Comparator for score + deterministic tie-break fields.
1280
+ * Negative return means "a ranks ahead of b" for descending score order.
1281
+ */
1282
+ _compareScoredRows(a, b) {
1283
+ const scoreDiff = b.score - a.score;
1284
+ if (scoreDiff !== 0) return scoreDiff;
1285
+ const accessCountDiff = (b.access_count ?? 0) - (a.access_count ?? 0);
1286
+ if (accessCountDiff !== 0) return accessCountDiff;
1287
+ const updatedAtDiff = (b.updated_at ?? 0) - (a.updated_at ?? 0);
1288
+ if (updatedAtDiff !== 0) return updatedAtDiff;
1289
+ return a.id.localeCompare(b.id);
1290
+ }
1291
+ /**
1292
+ * Score candidate rows using in-process JS cosine similarity.
1293
+ * Applies hybrid blending (if weight set) and tie-break sorting before returning.
1294
+ */
1295
+ async _rankWithJsCosine(args) {
1296
+ const { entityId, queryVec, candidateRows, weight, miniSearchScores, populateCache, limit } = args;
1297
+ let entityCache = this.vectorCache.get(entityId);
1298
+ const tooLarge = populateCache && candidateRows.length > _WikiMemory.MAX_VECTOR_CACHE_FACTS_PER_ENTITY;
1299
+ if (tooLarge && entityCache) {
1300
+ this.vectorCache.delete(entityId);
1301
+ entityCache = void 0;
1302
+ }
1303
+ const canCache = populateCache && !tooLarge;
1304
+ if (canCache && !entityCache) {
1305
+ entityCache = /* @__PURE__ */ new Map();
1306
+ }
1307
+ const scored = candidateRows.map((row) => {
1308
+ let vector = entityCache?.get(row.id) ?? parseEmbedding(row.embedding_blob, row.embedding);
1309
+ if (vector && canCache && entityCache && !entityCache.has(row.id)) {
1310
+ entityCache.set(row.id, vector);
1311
+ }
1312
+ let score = 0;
1313
+ if (vector && vector.length === queryVec.length) {
1314
+ const cosSim = cosineSimilarity(queryVec, vector);
1315
+ if (weight !== void 0) {
1316
+ const kwScore = miniSearchScores?.get(row.id) ?? 0;
1317
+ score = weight * Math.max(0, cosSim) + (1 - weight) * kwScore;
1318
+ } else {
1319
+ score = cosSim;
1320
+ }
1321
+ } else if (weight !== void 0 && weight < 1) {
1322
+ const kwScore = miniSearchScores?.get(row.id) ?? 0;
1323
+ score = (1 - weight) * kwScore;
1324
+ } else {
1325
+ score = -2;
1326
+ }
1327
+ return { id: row.id, score, updated_at: row.updated_at, access_count: row.access_count };
1328
+ });
1329
+ if (canCache && entityCache && entityCache.size > 0) {
1330
+ if (!this.vectorCache.has(entityId)) {
1331
+ if (this.vectorCache.size >= _WikiMemory.MAX_VECTOR_CACHE_ENTITIES) {
1332
+ const oldestKey = this.vectorCache.keys().next().value;
1333
+ if (oldestKey !== void 0) this.vectorCache.delete(oldestKey);
1334
+ }
1335
+ this.vectorCache.set(entityId, entityCache);
1336
+ }
1337
+ }
1338
+ this._tieBreakSort(scored);
1339
+ return scored.slice(0, limit);
1340
+ }
1341
+ /**
1342
+ * Delegate semantic ranking to the injected VectorRanker.
1343
+ * Caller should pass an oversampledLimit to preserve recall after re-ranking.
1344
+ * Returns scored results ready for hybrid blending and tie-break sorting.
1345
+ */
1346
+ async _rankWithVectorRanker(args) {
1347
+ const { entityId, queryVec, candidateIds, weight, miniSearchScores, limit } = args;
1348
+ const ranker = this.options.vectorRanker;
1349
+ if (!ranker) {
1350
+ throw new Error("vectorRanker not configured");
1351
+ }
1352
+ const rankerResults = await ranker.rankBySimilarity({
1353
+ entityId,
1354
+ queryVec,
1355
+ candidateIds,
1356
+ limit
1357
+ });
1358
+ const allowedIds = candidateIds ? new Set(candidateIds) : void 0;
1359
+ const seen = /* @__PURE__ */ new Set();
1360
+ const normalized = [];
1361
+ for (const r of rankerResults) {
1362
+ if (normalized.length >= limit) break;
1363
+ if (seen.has(r.id)) continue;
1364
+ if (allowedIds && !allowedIds.has(r.id)) continue;
1365
+ if (!Number.isFinite(r.semanticScore)) continue;
1366
+ seen.add(r.id);
1367
+ normalized.push(r);
1368
+ }
1369
+ const scored = normalized.map((r) => {
1370
+ let score = r.semanticScore;
1371
+ if (weight !== void 0) {
1372
+ const kwScore = miniSearchScores?.get(r.id) ?? 0;
1373
+ score = weight * Math.max(0, r.semanticScore) + (1 - weight) * kwScore;
1374
+ }
1375
+ return { id: r.id, score };
1376
+ });
1377
+ return scored;
1378
+ }
1035
1379
  async getMemoryBundle(entityId) {
1036
1380
  return this._getFullBundle(entityId, { maxEvents: 10 });
1037
1381
  }
@@ -1148,7 +1492,7 @@ ${JSON.stringify(currentFacts, null, 2)}`;
1148
1492
  INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, created_at, updated_at)
1149
1493
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1150
1494
  `, [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "agent_inferred", now, now]);
1151
- insertedFacts.push({ id, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1495
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1152
1496
  }
1153
1497
  for (const task of validTasks) {
1154
1498
  const id = generateId("task_");
@@ -1228,6 +1572,7 @@ The following document anchors are provided for contradiction detection only. Do
1228
1572
  const safeDeleted = deleted.filter((id) => mutableIds.has(id));
1229
1573
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
1230
1574
  const insertedFacts = [];
1575
+ const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
1231
1576
  await this.db.withTransactionAsync(async () => {
1232
1577
  for (const id of safeDowngraded) {
1233
1578
  await this.db.runAsync(`UPDATE ${this.prefix}entries SET confidence = 'tentative', updated_at = ? WHERE id = ? AND entity_id = ?`, [now, id, entityId]);
@@ -1241,11 +1586,18 @@ The following document anchors are provided for contradiction detection only. Do
1241
1586
  INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, created_at, updated_at)
1242
1587
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1243
1588
  `, [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "agent_inferred", now, now]);
1244
- insertedFacts.push({ id, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1589
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1245
1590
  }
1246
1591
  });
1247
1592
  this.vectorCache.delete(entityId);
1248
1593
  await this.rebuildMiniSearchIndex(entityId);
1594
+ for (const factId of uniqueDeletedFactIds) {
1595
+ try {
1596
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
1597
+ } catch (hookErr) {
1598
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during heal for ${factId}:`, hookErr);
1599
+ }
1600
+ }
1249
1601
  for (const fact of insertedFacts) {
1250
1602
  await this.embedFact(fact);
1251
1603
  }
@@ -1545,10 +1897,17 @@ The following document anchors are provided for contradiction detection only. Do
1545
1897
  }
1546
1898
  async _doImportEntity(entityId, bundle, merge) {
1547
1899
  const upsertedFactIds = /* @__PURE__ */ new Set();
1548
- const factsWithPreservedBlob = /* @__PURE__ */ new Set();
1900
+ const upsertedDeletedFactIds = /* @__PURE__ */ new Set();
1901
+ const factsWithPreservedBlob = /* @__PURE__ */ new Map();
1549
1902
  const preservedBlobDims = /* @__PURE__ */ new Set();
1903
+ const softDeletedFactIds = [];
1550
1904
  await this.db.withTransactionAsync(async () => {
1551
1905
  if (!merge) {
1906
+ const toDelete = await this.db.getAllAsync(
1907
+ `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
1908
+ [entityId]
1909
+ );
1910
+ softDeletedFactIds.push(...toDelete.map((r) => r.id));
1552
1911
  const now = Date.now();
1553
1912
  await this.db.runAsync(
1554
1913
  `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`,
@@ -1602,7 +1961,8 @@ The following document anchors are provided for contradiction detection only. Do
1602
1961
  let blobData = null;
1603
1962
  if (rawBlob !== null && rawBlob.byteLength > 0 && rawBlob.byteLength % 4 === 0) {
1604
1963
  const copy = new ArrayBuffer(rawBlob.byteLength);
1605
- new Uint8Array(copy).set(rawBlob);
1964
+ const alignedBlob = new Uint8Array(copy);
1965
+ alignedBlob.set(rawBlob);
1606
1966
  const floats = new Float32Array(copy, 0, rawBlob.byteLength / 4);
1607
1967
  let allFinite = true;
1608
1968
  for (let i = 0; i < floats.length; i++) {
@@ -1612,7 +1972,7 @@ The following document anchors are provided for contradiction detection only. Do
1612
1972
  }
1613
1973
  }
1614
1974
  if (allFinite) {
1615
- blobData = rawBlob;
1975
+ blobData = alignedBlob;
1616
1976
  }
1617
1977
  }
1618
1978
  if (existing) {
@@ -1628,7 +1988,7 @@ The following document anchors are provided for contradiction detection only. Do
1628
1988
  `UPDATE ${this.prefix}entries SET entity_id = ?, title = ?, body = ?, tags = ?, confidence = ?, source_type = ?, source_hash = ?, source_ref = ?, created_at = ?, updated_at = ?, last_accessed_at = ?, access_count = ?, deleted_at = ?, embedding_blob = ?, embedding = NULL WHERE id = ?`,
1629
1989
  [entityId, fact.title, fact.body, tagsJson, fact.confidence, fact.source_type, fact.source_hash, fact.source_ref, fact.created_at, safeUpdatedAt, fact.last_accessed_at, fact.access_count, fact.deleted_at, blobData, fact.id]
1630
1990
  );
1631
- factsWithPreservedBlob.add(fact.id);
1991
+ factsWithPreservedBlob.set(fact.id, blobData);
1632
1992
  if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
1633
1993
  } else {
1634
1994
  await this.db.runAsync(
@@ -1638,13 +1998,14 @@ The following document anchors are provided for contradiction detection only. Do
1638
1998
  }
1639
1999
  existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
1640
2000
  upsertedFactIds.add(fact.id);
2001
+ if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
1641
2002
  } else {
1642
2003
  if (blobData != null) {
1643
2004
  await this.db.runAsync(
1644
2005
  `INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, source_hash, source_ref, created_at, updated_at, last_accessed_at, access_count, deleted_at, embedding_blob) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1645
2006
  [fact.id, entityId, fact.title, fact.body, tagsJson, fact.confidence, fact.source_type, fact.source_hash, fact.source_ref, fact.created_at, safeUpdatedAt, fact.last_accessed_at, fact.access_count, fact.deleted_at, blobData]
1646
2007
  );
1647
- factsWithPreservedBlob.add(fact.id);
2008
+ factsWithPreservedBlob.set(fact.id, blobData);
1648
2009
  if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
1649
2010
  } else {
1650
2011
  await this.db.runAsync(
@@ -1654,6 +2015,7 @@ The following document anchors are provided for contradiction detection only. Do
1654
2015
  }
1655
2016
  existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
1656
2017
  upsertedFactIds.add(fact.id);
2018
+ if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
1657
2019
  }
1658
2020
  }
1659
2021
  const taskIds = bundle.tasks.map((task) => task.id);
@@ -1709,12 +2071,34 @@ The following document anchors are provided for contradiction detection only. Do
1709
2071
  if (!fact.deleted_at && upsertedFactIds.has(fact.id) && !factsWithPreservedBlob.has(fact.id)) {
1710
2072
  await this.embedFact({
1711
2073
  id: fact.id,
2074
+ entity_id: entityId,
2075
+ // Use authoritative entityId from dump key, not fact.entity_id
1712
2076
  title: fact.title,
1713
2077
  body: fact.body,
1714
2078
  tags: Array.isArray(fact.tags) || typeof fact.tags === "string" ? fact.tags : []
1715
2079
  });
1716
2080
  }
1717
2081
  }
2082
+ for (const fact of bundle.facts) {
2083
+ const blobData = factsWithPreservedBlob.get(fact.id);
2084
+ if (blobData && !fact.deleted_at && upsertedFactIds.has(fact.id)) {
2085
+ try {
2086
+ const float32Vector = new Float32Array(blobData.buffer, blobData.byteOffset, blobData.byteLength / 4);
2087
+ await this._notifyEmbeddingPersisted(entityId, fact.id, float32Vector);
2088
+ } catch (hookErr) {
2089
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed for preserved-blob fact ${fact.id}:`, hookErr);
2090
+ }
2091
+ }
2092
+ }
2093
+ for (const factId of softDeletedFactIds) {
2094
+ if (!upsertedFactIds.has(factId) || upsertedDeletedFactIds.has(factId)) {
2095
+ try {
2096
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
2097
+ } catch (hookErr) {
2098
+ console.warn(`[WikiMemory] onEmbeddingPersisted(vector=null) hook failed for soft-deleted fact ${factId}:`, hookErr);
2099
+ }
2100
+ }
2101
+ }
1718
2102
  try {
1719
2103
  const canonicalRow = await this.db.getFirstAsync(
1720
2104
  `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension'`
@@ -1785,7 +2169,13 @@ The following document anchors are provided for contradiction detection only. Do
1785
2169
  const now = Date.now();
1786
2170
  let deletedEntries = 0;
1787
2171
  let deletedTasks = 0;
2172
+ const deletedEntryIds = [];
1788
2173
  if (params.clearAll) {
2174
+ const entriesToDelete = await this.db.getAllAsync(
2175
+ `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
2176
+ [entityId]
2177
+ );
2178
+ deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
1789
2179
  const [entriesRes, tasksRes] = await Promise.all([
1790
2180
  this.db.runAsync(`UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`, [now, now, entityId]),
1791
2181
  this.db.runAsync(`UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`, [now, now, entityId])
@@ -1803,6 +2193,27 @@ The following document anchors are provided for contradiction detection only. Do
1803
2193
  if (params.sourceRef !== void 0 && !sourceRef) throw new Error("Invalid sourceRef");
1804
2194
  const sourceHash = params.sourceHash !== void 0 ? normalizeSourceHash(params.sourceHash) : null;
1805
2195
  if (params.sourceHash !== void 0 && !sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
2196
+ if (params.entryId) {
2197
+ const entry = await this.db.getFirstAsync(
2198
+ `SELECT id FROM ${this.prefix}entries WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`,
2199
+ [params.entryId, entityId]
2200
+ );
2201
+ if (entry) deletedEntryIds.push(entry.id);
2202
+ }
2203
+ if (sourceRef || sourceHash) {
2204
+ let q = `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`;
2205
+ const args = [entityId];
2206
+ if (sourceRef) {
2207
+ q += ` AND source_ref = ?`;
2208
+ args.push(sourceRef);
2209
+ }
2210
+ if (sourceHash) {
2211
+ q += ` AND source_hash = ?`;
2212
+ args.push(sourceHash);
2213
+ }
2214
+ const entriesToDelete = await this.db.getAllAsync(q, args);
2215
+ deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
2216
+ }
1806
2217
  const entryPromise = params.entryId ? this.db.runAsync(`UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`, [now, now, params.entryId, entityId]) : null;
1807
2218
  const taskPromise = params.taskId ? this.db.runAsync(`UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`, [now, now, params.taskId, entityId]) : null;
1808
2219
  let refPromise = null;
@@ -1830,6 +2241,14 @@ The following document anchors are provided for contradiction detection only. Do
1830
2241
  }
1831
2242
  await this.rebuildMiniSearchIndex(entityId);
1832
2243
  this.vectorCache.delete(entityId);
2244
+ const uniqueDeletedIds = Array.from(new Set(deletedEntryIds));
2245
+ for (const factId of uniqueDeletedIds) {
2246
+ try {
2247
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
2248
+ } catch (hookErr) {
2249
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during forget for ${factId}:`, hookErr);
2250
+ }
2251
+ }
1833
2252
  return { deleted: { entries: deletedEntries, tasks: deletedTasks } };
1834
2253
  } finally {
1835
2254
  this.activeMaintenanceJobs.delete(forgetKey);
@@ -1899,7 +2318,15 @@ ${chunk}`;
1899
2318
  }
1900
2319
  const now = Date.now();
1901
2320
  const insertedFacts = [];
2321
+ const deletedSourceFactIds = [];
1902
2322
  await this.db.withTransactionAsync(async () => {
2323
+ const existingSourceFacts = await this.db.getAllAsync(
2324
+ `SELECT id FROM ${this.prefix}entries WHERE source_ref = ? AND entity_id = ? AND deleted_at IS NULL`,
2325
+ [sourceRef, entityId]
2326
+ );
2327
+ for (const row of existingSourceFacts) {
2328
+ deletedSourceFactIds.push(row.id);
2329
+ }
1903
2330
  await this.db.runAsync(
1904
2331
  `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE source_ref = ? AND entity_id = ? AND deleted_at IS NULL`,
1905
2332
  [now, now, sourceRef, entityId]
@@ -1911,11 +2338,19 @@ ${chunk}`;
1911
2338
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1912
2339
  [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "user_document", sourceHash, sourceRef, now, now]
1913
2340
  );
1914
- insertedFacts.push({ id, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
2341
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1915
2342
  }
1916
2343
  });
1917
2344
  await this.rebuildMiniSearchIndex(entityId);
1918
2345
  this.vectorCache.delete(entityId);
2346
+ const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
2347
+ for (const factId of uniqueDeletedSourceFactIds) {
2348
+ try {
2349
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
2350
+ } catch (hookErr) {
2351
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during ingest for ${factId}:`, hookErr);
2352
+ }
2353
+ }
1919
2354
  for (const fact of insertedFacts) {
1920
2355
  await this.embedFact(fact);
1921
2356
  }