@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.mjs CHANGED
@@ -554,6 +554,11 @@ var _WikiMemory = class _WikiMemory {
554
554
  `UPDATE ${this.prefix}entries SET embedding_blob = ?, embedding = NULL WHERE id = ?`,
555
555
  [blob, fact.id]
556
556
  );
557
+ try {
558
+ await this._notifyEmbeddingPersisted(fact.entity_id, fact.id, float32Vector);
559
+ } catch (hookErr) {
560
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed for ${fact.id}:`, hookErr);
561
+ }
557
562
  return true;
558
563
  } catch (err) {
559
564
  console.warn(`[WikiMemory] embedFact failed for ${fact.id}:`, err);
@@ -569,6 +574,9 @@ var _WikiMemory = class _WikiMemory {
569
574
  _warnCrossEntityCollision(type, id, existingEntityId, targetEntityId) {
570
575
  console.warn(`[WikiMemory] importDump: ${type} id "${id}" already belongs to entity "${existingEntityId}"; skipping for entity "${targetEntityId}"`);
571
576
  }
577
+ async _notifyEmbeddingPersisted(entityId, factId, vector) {
578
+ await this.options.vectorRanker?.onEmbeddingPersisted?.({ entityId, factId, vector });
579
+ }
572
580
  async setup() {
573
581
  const entriesExistedBeforeSetup = await this.db.getFirstAsync(
574
582
  `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
@@ -751,8 +759,15 @@ var _WikiMemory = class _WikiMemory {
751
759
  let deletedEntries = 0;
752
760
  let deletedTasks = 0;
753
761
  let deletedEvents = 0;
762
+ const deletedEntryIds = [];
754
763
  if (retainSoftDeletedFor !== null) {
755
764
  const cutoff = now - retainSoftDeletedFor * 864e5;
765
+ const entriesToDelete = await this.db.getAllAsync(
766
+ `SELECT id FROM ${this.prefix}entries
767
+ WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at < ?`,
768
+ [entityId, cutoff]
769
+ );
770
+ deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
756
771
  const entryResult = await this.db.runAsync(
757
772
  `DELETE FROM ${this.prefix}entries
758
773
  WHERE entity_id = ? AND deleted_at IS NOT NULL AND deleted_at < ?`,
@@ -781,6 +796,14 @@ var _WikiMemory = class _WikiMemory {
781
796
  }
782
797
  await this.rebuildMiniSearchIndex(entityId);
783
798
  this.vectorCache.delete(entityId);
799
+ const uniqueDeletedIds = Array.from(new Set(deletedEntryIds));
800
+ for (const factId of uniqueDeletedIds) {
801
+ try {
802
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
803
+ } catch (hookErr) {
804
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during prune for ${factId}:`, hookErr);
805
+ }
806
+ }
784
807
  return { entries: deletedEntries, tasks: deletedTasks, events: deletedEvents };
785
808
  } finally {
786
809
  this.activeMaintenanceJobs.delete(pruneKey);
@@ -801,6 +824,10 @@ var _WikiMemory = class _WikiMemory {
801
824
  if (maxResults === 0) ; else if (trimmedQuery) {
802
825
  let usedEmbed = false;
803
826
  if (!skipEmbed && embedFn) {
827
+ let rankerShouldRethrow = false;
828
+ let pendingRankerFallbackError;
829
+ let usedKeywordFallback = false;
830
+ let scoredAlreadySortedAndLimited = false;
804
831
  try {
805
832
  const queryVec = await embedFn(trimmedQuery);
806
833
  if (queryVec.length === 0 || !queryVec.every((v) => typeof v === "number" && isFinite(v))) {
@@ -832,6 +859,7 @@ var _WikiMemory = class _WikiMemory {
832
859
  `Some facts have embeddings that do not match the current model dimension. Call runReembed() to rebuild all embeddings consistently.`
833
860
  );
834
861
  }
862
+ const useRanker = Boolean(this.options.vectorRanker);
835
863
  let candidateRows;
836
864
  let populateCache = true;
837
865
  let miniSearchScores;
@@ -850,15 +878,30 @@ var _WikiMemory = class _WikiMemory {
850
878
  } else {
851
879
  const topKIds = topKResults.map((r) => r.id);
852
880
  const inClauseChunkSize = 500;
853
- candidateRows = [];
854
- for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
855
- const idChunk = topKIds.slice(i, i + inClauseChunkSize);
856
- const placeholders = idChunk.map(() => "?").join(",");
857
- const chunkRows = await this.db.getAllAsync(
858
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
859
- idChunk
860
- );
861
- candidateRows.push(...chunkRows);
881
+ if (useRanker) {
882
+ const rows = [];
883
+ for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
884
+ const idChunk = topKIds.slice(i, i + inClauseChunkSize);
885
+ const placeholders = idChunk.map(() => "?").join(",");
886
+ const chunkRows = await this.db.getAllAsync(
887
+ `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
888
+ idChunk
889
+ );
890
+ rows.push(...chunkRows);
891
+ }
892
+ candidateRows = rows;
893
+ } else {
894
+ const rows = [];
895
+ for (let i = 0; i < topKIds.length; i += inClauseChunkSize) {
896
+ const idChunk = topKIds.slice(i, i + inClauseChunkSize);
897
+ const placeholders = idChunk.map(() => "?").join(",");
898
+ const chunkRows = await this.db.getAllAsync(
899
+ `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
900
+ idChunk
901
+ );
902
+ rows.push(...chunkRows);
903
+ }
904
+ candidateRows = rows;
862
905
  }
863
906
  if (weight !== void 0 && weight < 1) {
864
907
  const maxMsScore = Math.max(1, topKResults[0]?.score ?? 1);
@@ -867,10 +910,17 @@ var _WikiMemory = class _WikiMemory {
867
910
  }
868
911
  }
869
912
  } else {
870
- candidateRows = await this.db.getAllAsync(
871
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
872
- [entityId]
873
- );
913
+ if (useRanker) {
914
+ candidateRows = await this.db.getAllAsync(
915
+ `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
916
+ [entityId]
917
+ );
918
+ } else {
919
+ candidateRows = await this.db.getAllAsync(
920
+ `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
921
+ [entityId]
922
+ );
923
+ }
874
924
  if (weight !== void 0 && weight < 1) {
875
925
  const msResults = this.miniSearch.search(trimmedQuery, {
876
926
  filter: (r) => r.entity_id === entityId,
@@ -883,76 +933,263 @@ var _WikiMemory = class _WikiMemory {
883
933
  if (candidateRows === null) {
884
934
  usedEmbed = true;
885
935
  } else {
886
- let entityCache = this.vectorCache.get(entityId);
887
- const tooLarge = populateCache && candidateRows.length > _WikiMemory.MAX_VECTOR_CACHE_FACTS_PER_ENTITY;
888
- if (tooLarge && entityCache) {
889
- this.vectorCache.delete(entityId);
890
- entityCache = void 0;
891
- }
892
- const canCache = populateCache && !tooLarge;
893
- if (canCache && !entityCache) {
894
- entityCache = /* @__PURE__ */ new Map();
895
- }
896
- const scored = candidateRows.map((row) => {
897
- let vector = entityCache?.get(row.id) ?? parseEmbedding(row.embedding_blob, row.embedding);
898
- if (vector && canCache && entityCache && !entityCache.has(row.id)) {
899
- entityCache.set(row.id, vector);
900
- }
901
- let score = 0;
902
- if (vector && vector.length === queryVec.length) {
903
- const cosSim = cosineSimilarity(queryVec, vector);
904
- if (weight !== void 0) {
905
- const kwScore = miniSearchScores?.get(row.id) ?? 0;
906
- score = weight * Math.max(0, cosSim) + (1 - weight) * kwScore;
936
+ let scored;
937
+ if (useRanker) {
938
+ const candidateIds = effectivePreFilterLimit !== void 0 ? candidateRows.map((r) => r.id) : void 0;
939
+ try {
940
+ const oversampledLimit = Math.max(maxResults * 2, maxResults + 50);
941
+ scored = await this._rankWithVectorRanker({
942
+ entityId,
943
+ queryVec,
944
+ candidateIds,
945
+ weight,
946
+ miniSearchScores,
947
+ limit: oversampledLimit
948
+ });
949
+ if (scored.length > 0) {
950
+ const scoredIds2 = new Set(scored.map((s) => s.id));
951
+ const metaMap = /* @__PURE__ */ new Map();
952
+ for (const r of candidateRows) {
953
+ if (scoredIds2.has(r.id)) {
954
+ metaMap.set(r.id, { updated_at: r.updated_at, access_count: r.access_count });
955
+ }
956
+ }
957
+ scored = scored.map((s) => {
958
+ const meta = metaMap.get(s.id);
959
+ return { ...s, updated_at: meta?.updated_at ?? null, access_count: meta?.access_count ?? null };
960
+ });
961
+ }
962
+ const scoredIds = new Set(scored.map((s) => s.id));
963
+ const isHybrid = weight !== void 0 && weight < 1;
964
+ const maxBackfill = isHybrid ? maxResults : Math.max(0, maxResults - scored.length);
965
+ if (maxBackfill > 0) {
966
+ if (isHybrid) {
967
+ const topK = [];
968
+ for (const row of candidateRows) {
969
+ if (scoredIds.has(row.id)) continue;
970
+ const kwScore = miniSearchScores?.get(row.id) ?? 0;
971
+ const candidate = { row, kwScore };
972
+ if (topK.length < maxBackfill) {
973
+ let insertIdx = topK.length;
974
+ for (let i = 0; i < topK.length; i++) {
975
+ const cmp = this._compareScoredRows(
976
+ {
977
+ id: candidate.row.id,
978
+ score: candidate.kwScore,
979
+ updated_at: candidate.row.updated_at,
980
+ access_count: candidate.row.access_count
981
+ },
982
+ {
983
+ id: topK[i].row.id,
984
+ score: topK[i].kwScore,
985
+ updated_at: topK[i].row.updated_at,
986
+ access_count: topK[i].row.access_count
987
+ }
988
+ );
989
+ if (cmp < 0) {
990
+ insertIdx = i;
991
+ break;
992
+ }
993
+ }
994
+ topK.splice(insertIdx, 0, candidate);
995
+ } else {
996
+ const cmpWorst = this._compareScoredRows(
997
+ {
998
+ id: candidate.row.id,
999
+ score: candidate.kwScore,
1000
+ updated_at: candidate.row.updated_at,
1001
+ access_count: candidate.row.access_count
1002
+ },
1003
+ {
1004
+ id: topK[maxBackfill - 1].row.id,
1005
+ score: topK[maxBackfill - 1].kwScore,
1006
+ updated_at: topK[maxBackfill - 1].row.updated_at,
1007
+ access_count: topK[maxBackfill - 1].row.access_count
1008
+ }
1009
+ );
1010
+ if (cmpWorst < 0) {
1011
+ let insertIdx = maxBackfill - 1;
1012
+ for (let i = 0; i < topK.length; i++) {
1013
+ const cmp = this._compareScoredRows(
1014
+ {
1015
+ id: candidate.row.id,
1016
+ score: candidate.kwScore,
1017
+ updated_at: candidate.row.updated_at,
1018
+ access_count: candidate.row.access_count
1019
+ },
1020
+ {
1021
+ id: topK[i].row.id,
1022
+ score: topK[i].kwScore,
1023
+ updated_at: topK[i].row.updated_at,
1024
+ access_count: topK[i].row.access_count
1025
+ }
1026
+ );
1027
+ if (cmp < 0) {
1028
+ insertIdx = i;
1029
+ break;
1030
+ }
1031
+ }
1032
+ topK.splice(insertIdx, 0, candidate);
1033
+ topK.pop();
1034
+ }
1035
+ }
1036
+ }
1037
+ for (const { row, kwScore } of topK) {
1038
+ scored.push({
1039
+ id: row.id,
1040
+ score: (1 - weight) * kwScore,
1041
+ updated_at: row.updated_at,
1042
+ access_count: row.access_count
1043
+ });
1044
+ }
1045
+ } else {
1046
+ const omitted = [];
1047
+ for (const row of candidateRows) {
1048
+ if (scoredIds.has(row.id)) continue;
1049
+ omitted.push({ id: row.id, score: -2, updated_at: row.updated_at, access_count: row.access_count });
1050
+ }
1051
+ if (omitted.length > 0) {
1052
+ this._tieBreakSort(omitted);
1053
+ scored.push(...omitted.slice(0, maxBackfill));
1054
+ }
1055
+ }
1056
+ }
1057
+ } catch (rankerErr) {
1058
+ const rankerError = rankerErr instanceof Error ? rankerErr : new Error(String(rankerErr));
1059
+ const policy = this.options.vectorRankerFallback ?? "js-cosine";
1060
+ this.options.onVectorRankerFallback?.({ error: rankerError, policy });
1061
+ if (policy === "throw") {
1062
+ rankerShouldRethrow = true;
1063
+ throw rankerError;
1064
+ } else if (policy === "js-cosine") {
1065
+ let fallbackRows = candidateRows;
1066
+ if (fallbackRows && fallbackRows.length > 0 && !("embedding_blob" in fallbackRows[0])) {
1067
+ const rowIds = fallbackRows.map((r) => r.id);
1068
+ const embeddingsMap = /* @__PURE__ */ new Map();
1069
+ const chunkSize = 500;
1070
+ for (let i = 0; i < rowIds.length; i += chunkSize) {
1071
+ const idChunk = rowIds.slice(i, i + chunkSize);
1072
+ const placeholders = idChunk.map(() => "?").join(",");
1073
+ const embeddingRows = await this.db.getAllAsync(
1074
+ `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1075
+ [...idChunk, entityId]
1076
+ );
1077
+ for (const row of embeddingRows) {
1078
+ embeddingsMap.set(row.id, { embedding_blob: row.embedding_blob, embedding: row.embedding });
1079
+ }
1080
+ }
1081
+ fallbackRows = fallbackRows.map((r) => ({
1082
+ ...r,
1083
+ embedding_blob: embeddingsMap.get(r.id)?.embedding_blob ?? null,
1084
+ embedding: embeddingsMap.get(r.id)?.embedding ?? null
1085
+ }));
1086
+ }
1087
+ scored = await this._rankWithJsCosine({
1088
+ entityId,
1089
+ queryVec,
1090
+ candidateRows: fallbackRows,
1091
+ weight,
1092
+ miniSearchScores,
1093
+ populateCache,
1094
+ limit: maxResults
1095
+ });
1096
+ scoredAlreadySortedAndLimited = true;
1097
+ } else if (policy === "keyword") {
1098
+ const msResults = this.miniSearch.search(trimmedQuery, {
1099
+ filter: (r) => r.entity_id === entityId,
1100
+ combineWith: "OR"
1101
+ });
1102
+ const topResults = msResults.slice(0, maxResults);
1103
+ const resultIds = new Set(topResults.map((r) => r.id));
1104
+ const candidateMap = /* @__PURE__ */ new Map();
1105
+ for (const r of candidateRows) {
1106
+ if (resultIds.has(r.id)) {
1107
+ candidateMap.set(r.id, { updated_at: r.updated_at, access_count: r.access_count });
1108
+ }
1109
+ }
1110
+ scored = topResults.map((r) => {
1111
+ const meta = candidateMap.get(r.id);
1112
+ return {
1113
+ id: r.id,
1114
+ score: r.score ?? 0,
1115
+ access_count: meta?.access_count ?? null,
1116
+ updated_at: meta?.updated_at ?? null
1117
+ };
1118
+ });
1119
+ usedKeywordFallback = true;
907
1120
  } else {
908
- score = cosSim;
1121
+ scored = [];
909
1122
  }
910
- } else if (weight !== void 0 && weight < 1) {
911
- const kwScore = miniSearchScores?.get(row.id) ?? 0;
912
- score = (1 - weight) * kwScore;
913
- } else {
914
- score = -2;
915
- }
916
- return { row, score };
917
- });
918
- if (canCache && entityCache && entityCache.size > 0) {
919
- if (!this.vectorCache.has(entityId)) {
920
- if (this.vectorCache.size >= _WikiMemory.MAX_VECTOR_CACHE_ENTITIES) {
921
- const oldestKey = this.vectorCache.keys().next().value;
922
- if (oldestKey !== void 0) this.vectorCache.delete(oldestKey);
1123
+ if (this.options.propagateRankerFailureToRetrievalFallback) {
1124
+ const mirrored = new Error("Vector ranker failed, falling back");
1125
+ mirrored.cause = rankerError;
1126
+ pendingRankerFallbackError = mirrored;
923
1127
  }
924
- this.vectorCache.set(entityId, entityCache);
925
1128
  }
1129
+ } else {
1130
+ scored = await this._rankWithJsCosine({
1131
+ entityId,
1132
+ queryVec,
1133
+ candidateRows,
1134
+ weight,
1135
+ miniSearchScores,
1136
+ populateCache,
1137
+ limit: maxResults
1138
+ });
1139
+ scoredAlreadySortedAndLimited = true;
926
1140
  }
927
- scored.sort((a, b) => {
928
- const scoreDiff = b.score - a.score;
929
- if (scoreDiff !== 0) return scoreDiff;
930
- const accessCountDiff = (b.row.access_count ?? 0) - (a.row.access_count ?? 0);
931
- if (accessCountDiff !== 0) return accessCountDiff;
932
- const updatedAtDiff = (b.row.updated_at ?? 0) - (a.row.updated_at ?? 0);
933
- if (updatedAtDiff !== 0) return updatedAtDiff;
934
- return a.row.id.localeCompare(b.row.id);
935
- });
936
- const topIds = scored.slice(0, maxResults).map((s) => s.row.id);
937
- if (topIds.length > 0) {
938
- const fullRows = [];
939
- const phase2ChunkSize = 500;
940
- for (let i = 0; i < topIds.length; i += phase2ChunkSize) {
941
- const idChunk = topIds.slice(i, i + phase2ChunkSize);
942
- const placeholders = idChunk.map(() => "?").join(",");
943
- const chunkRows = await this.db.getAllAsync(
944
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
945
- idChunk
946
- );
947
- fullRows.push(...chunkRows);
1141
+ if (scored.length > 0) {
1142
+ if (!usedKeywordFallback && !scoredAlreadySortedAndLimited) {
1143
+ this._tieBreakSort(scored);
1144
+ }
1145
+ const topIds = (scoredAlreadySortedAndLimited ? scored : scored.slice(0, maxResults)).map((s) => s.id);
1146
+ if (topIds.length > 0) {
1147
+ const fullRows = [];
1148
+ const phase2ChunkSize = 500;
1149
+ for (let i = 0; i < topIds.length; i += phase2ChunkSize) {
1150
+ const idChunk = topIds.slice(i, i + phase2ChunkSize);
1151
+ const placeholders = idChunk.map(() => "?").join(",");
1152
+ const chunkRows = await this.db.getAllAsync(
1153
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1154
+ [...idChunk, entityId]
1155
+ );
1156
+ fullRows.push(...chunkRows);
1157
+ }
1158
+ const byId = new Map(fullRows.map((r) => [r.id, r]));
1159
+ facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1160
+ if (facts.length < topIds.length) {
1161
+ const missingIds = topIds.filter((id) => !byId.has(id));
1162
+ const missingCount = missingIds.length;
1163
+ const sample = missingIds.slice(0, 5);
1164
+ const sampleSuffix = sample.length > 0 ? ` Missing ID sample: ${sample.join(", ")}${missingIds.length > sample.length ? ", ..." : ""}.` : "";
1165
+ const error = new Error(
1166
+ `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
1167
+ );
1168
+ this.options.onRetrievalFallback?.(error);
1169
+ }
1170
+ }
1171
+ if (pendingRankerFallbackError) {
1172
+ this.options.onRetrievalFallback?.(pendingRankerFallbackError);
1173
+ pendingRankerFallbackError = void 0;
1174
+ }
1175
+ usedEmbed = true;
1176
+ } else {
1177
+ if (pendingRankerFallbackError) {
1178
+ this.options.onRetrievalFallback?.(pendingRankerFallbackError);
1179
+ pendingRankerFallbackError = void 0;
948
1180
  }
949
- const byId = new Map(fullRows.map((r) => [r.id, r]));
950
- facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1181
+ usedEmbed = true;
951
1182
  }
952
- usedEmbed = true;
953
1183
  }
954
1184
  } catch (err) {
955
1185
  const error = err instanceof Error ? err : new Error(String(err));
1186
+ if (rankerShouldRethrow) {
1187
+ throw error;
1188
+ }
1189
+ if (pendingRankerFallbackError) {
1190
+ error.cause = pendingRankerFallbackError;
1191
+ pendingRankerFallbackError = void 0;
1192
+ }
956
1193
  this.options.onRetrievalFallback?.(error);
957
1194
  }
958
1195
  }
@@ -969,8 +1206,8 @@ var _WikiMemory = class _WikiMemory {
969
1206
  const idChunk = topIds.slice(i, i + kwChunkSize);
970
1207
  const placeholders = idChunk.map(() => "?").join(",");
971
1208
  const chunkRows = await this.db.getAllAsync(
972
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
973
- idChunk
1209
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1210
+ [...idChunk, entityId]
974
1211
  );
975
1212
  kwRows.push(...chunkRows);
976
1213
  }
@@ -1026,6 +1263,113 @@ var _WikiMemory = class _WikiMemory {
1026
1263
  });
1027
1264
  return { facts: parsedFacts, tasks, events: events.reverse() };
1028
1265
  }
1266
+ /**
1267
+ * Stable tie-break sort: score desc → access_count desc → updated_at desc → id asc.
1268
+ */
1269
+ _tieBreakSort(items) {
1270
+ items.sort((a, b) => this._compareScoredRows(a, b));
1271
+ }
1272
+ /**
1273
+ * Comparator for score + deterministic tie-break fields.
1274
+ * Negative return means "a ranks ahead of b" for descending score order.
1275
+ */
1276
+ _compareScoredRows(a, b) {
1277
+ const scoreDiff = b.score - a.score;
1278
+ if (scoreDiff !== 0) return scoreDiff;
1279
+ const accessCountDiff = (b.access_count ?? 0) - (a.access_count ?? 0);
1280
+ if (accessCountDiff !== 0) return accessCountDiff;
1281
+ const updatedAtDiff = (b.updated_at ?? 0) - (a.updated_at ?? 0);
1282
+ if (updatedAtDiff !== 0) return updatedAtDiff;
1283
+ return a.id.localeCompare(b.id);
1284
+ }
1285
+ /**
1286
+ * Score candidate rows using in-process JS cosine similarity.
1287
+ * Applies hybrid blending (if weight set) and tie-break sorting before returning.
1288
+ */
1289
+ async _rankWithJsCosine(args) {
1290
+ const { entityId, queryVec, candidateRows, weight, miniSearchScores, populateCache, limit } = args;
1291
+ let entityCache = this.vectorCache.get(entityId);
1292
+ const tooLarge = populateCache && candidateRows.length > _WikiMemory.MAX_VECTOR_CACHE_FACTS_PER_ENTITY;
1293
+ if (tooLarge && entityCache) {
1294
+ this.vectorCache.delete(entityId);
1295
+ entityCache = void 0;
1296
+ }
1297
+ const canCache = populateCache && !tooLarge;
1298
+ if (canCache && !entityCache) {
1299
+ entityCache = /* @__PURE__ */ new Map();
1300
+ }
1301
+ const scored = candidateRows.map((row) => {
1302
+ let vector = entityCache?.get(row.id) ?? parseEmbedding(row.embedding_blob, row.embedding);
1303
+ if (vector && canCache && entityCache && !entityCache.has(row.id)) {
1304
+ entityCache.set(row.id, vector);
1305
+ }
1306
+ let score = 0;
1307
+ if (vector && vector.length === queryVec.length) {
1308
+ const cosSim = cosineSimilarity(queryVec, vector);
1309
+ if (weight !== void 0) {
1310
+ const kwScore = miniSearchScores?.get(row.id) ?? 0;
1311
+ score = weight * Math.max(0, cosSim) + (1 - weight) * kwScore;
1312
+ } else {
1313
+ score = cosSim;
1314
+ }
1315
+ } else if (weight !== void 0 && weight < 1) {
1316
+ const kwScore = miniSearchScores?.get(row.id) ?? 0;
1317
+ score = (1 - weight) * kwScore;
1318
+ } else {
1319
+ score = -2;
1320
+ }
1321
+ return { id: row.id, score, updated_at: row.updated_at, access_count: row.access_count };
1322
+ });
1323
+ if (canCache && entityCache && entityCache.size > 0) {
1324
+ if (!this.vectorCache.has(entityId)) {
1325
+ if (this.vectorCache.size >= _WikiMemory.MAX_VECTOR_CACHE_ENTITIES) {
1326
+ const oldestKey = this.vectorCache.keys().next().value;
1327
+ if (oldestKey !== void 0) this.vectorCache.delete(oldestKey);
1328
+ }
1329
+ this.vectorCache.set(entityId, entityCache);
1330
+ }
1331
+ }
1332
+ this._tieBreakSort(scored);
1333
+ return scored.slice(0, limit);
1334
+ }
1335
+ /**
1336
+ * Delegate semantic ranking to the injected VectorRanker.
1337
+ * Caller should pass an oversampledLimit to preserve recall after re-ranking.
1338
+ * Returns scored results ready for hybrid blending and tie-break sorting.
1339
+ */
1340
+ async _rankWithVectorRanker(args) {
1341
+ const { entityId, queryVec, candidateIds, weight, miniSearchScores, limit } = args;
1342
+ const ranker = this.options.vectorRanker;
1343
+ if (!ranker) {
1344
+ throw new Error("vectorRanker not configured");
1345
+ }
1346
+ const rankerResults = await ranker.rankBySimilarity({
1347
+ entityId,
1348
+ queryVec,
1349
+ candidateIds,
1350
+ limit
1351
+ });
1352
+ const allowedIds = candidateIds ? new Set(candidateIds) : void 0;
1353
+ const seen = /* @__PURE__ */ new Set();
1354
+ const normalized = [];
1355
+ for (const r of rankerResults) {
1356
+ if (normalized.length >= limit) break;
1357
+ if (seen.has(r.id)) continue;
1358
+ if (allowedIds && !allowedIds.has(r.id)) continue;
1359
+ if (!Number.isFinite(r.semanticScore)) continue;
1360
+ seen.add(r.id);
1361
+ normalized.push(r);
1362
+ }
1363
+ const scored = normalized.map((r) => {
1364
+ let score = r.semanticScore;
1365
+ if (weight !== void 0) {
1366
+ const kwScore = miniSearchScores?.get(r.id) ?? 0;
1367
+ score = weight * Math.max(0, r.semanticScore) + (1 - weight) * kwScore;
1368
+ }
1369
+ return { id: r.id, score };
1370
+ });
1371
+ return scored;
1372
+ }
1029
1373
  async getMemoryBundle(entityId) {
1030
1374
  return this._getFullBundle(entityId, { maxEvents: 10 });
1031
1375
  }
@@ -1142,7 +1486,7 @@ ${JSON.stringify(currentFacts, null, 2)}`;
1142
1486
  INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, created_at, updated_at)
1143
1487
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1144
1488
  `, [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "agent_inferred", now, now]);
1145
- insertedFacts.push({ id, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1489
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1146
1490
  }
1147
1491
  for (const task of validTasks) {
1148
1492
  const id = generateId("task_");
@@ -1222,6 +1566,7 @@ The following document anchors are provided for contradiction detection only. Do
1222
1566
  const safeDeleted = deleted.filter((id) => mutableIds.has(id));
1223
1567
  const validNewFacts = newFacts.map(validateFact).filter((f) => f !== null);
1224
1568
  const insertedFacts = [];
1569
+ const uniqueDeletedFactIds = Array.from(new Set(safeDeleted));
1225
1570
  await this.db.withTransactionAsync(async () => {
1226
1571
  for (const id of safeDowngraded) {
1227
1572
  await this.db.runAsync(`UPDATE ${this.prefix}entries SET confidence = 'tentative', updated_at = ? WHERE id = ? AND entity_id = ?`, [now, id, entityId]);
@@ -1235,11 +1580,18 @@ The following document anchors are provided for contradiction detection only. Do
1235
1580
  INSERT INTO ${this.prefix}entries (id, entity_id, title, body, tags, confidence, source_type, created_at, updated_at)
1236
1581
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1237
1582
  `, [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "agent_inferred", now, now]);
1238
- insertedFacts.push({ id, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1583
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1239
1584
  }
1240
1585
  });
1241
1586
  this.vectorCache.delete(entityId);
1242
1587
  await this.rebuildMiniSearchIndex(entityId);
1588
+ for (const factId of uniqueDeletedFactIds) {
1589
+ try {
1590
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
1591
+ } catch (hookErr) {
1592
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during heal for ${factId}:`, hookErr);
1593
+ }
1594
+ }
1243
1595
  for (const fact of insertedFacts) {
1244
1596
  await this.embedFact(fact);
1245
1597
  }
@@ -1539,10 +1891,17 @@ The following document anchors are provided for contradiction detection only. Do
1539
1891
  }
1540
1892
  async _doImportEntity(entityId, bundle, merge) {
1541
1893
  const upsertedFactIds = /* @__PURE__ */ new Set();
1542
- const factsWithPreservedBlob = /* @__PURE__ */ new Set();
1894
+ const upsertedDeletedFactIds = /* @__PURE__ */ new Set();
1895
+ const factsWithPreservedBlob = /* @__PURE__ */ new Map();
1543
1896
  const preservedBlobDims = /* @__PURE__ */ new Set();
1897
+ const softDeletedFactIds = [];
1544
1898
  await this.db.withTransactionAsync(async () => {
1545
1899
  if (!merge) {
1900
+ const toDelete = await this.db.getAllAsync(
1901
+ `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
1902
+ [entityId]
1903
+ );
1904
+ softDeletedFactIds.push(...toDelete.map((r) => r.id));
1546
1905
  const now = Date.now();
1547
1906
  await this.db.runAsync(
1548
1907
  `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`,
@@ -1596,7 +1955,8 @@ The following document anchors are provided for contradiction detection only. Do
1596
1955
  let blobData = null;
1597
1956
  if (rawBlob !== null && rawBlob.byteLength > 0 && rawBlob.byteLength % 4 === 0) {
1598
1957
  const copy = new ArrayBuffer(rawBlob.byteLength);
1599
- new Uint8Array(copy).set(rawBlob);
1958
+ const alignedBlob = new Uint8Array(copy);
1959
+ alignedBlob.set(rawBlob);
1600
1960
  const floats = new Float32Array(copy, 0, rawBlob.byteLength / 4);
1601
1961
  let allFinite = true;
1602
1962
  for (let i = 0; i < floats.length; i++) {
@@ -1606,7 +1966,7 @@ The following document anchors are provided for contradiction detection only. Do
1606
1966
  }
1607
1967
  }
1608
1968
  if (allFinite) {
1609
- blobData = rawBlob;
1969
+ blobData = alignedBlob;
1610
1970
  }
1611
1971
  }
1612
1972
  if (existing) {
@@ -1622,7 +1982,7 @@ The following document anchors are provided for contradiction detection only. Do
1622
1982
  `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 = ?`,
1623
1983
  [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]
1624
1984
  );
1625
- factsWithPreservedBlob.add(fact.id);
1985
+ factsWithPreservedBlob.set(fact.id, blobData);
1626
1986
  if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
1627
1987
  } else {
1628
1988
  await this.db.runAsync(
@@ -1632,13 +1992,14 @@ The following document anchors are provided for contradiction detection only. Do
1632
1992
  }
1633
1993
  existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
1634
1994
  upsertedFactIds.add(fact.id);
1995
+ if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
1635
1996
  } else {
1636
1997
  if (blobData != null) {
1637
1998
  await this.db.runAsync(
1638
1999
  `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1639
2000
  [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]
1640
2001
  );
1641
- factsWithPreservedBlob.add(fact.id);
2002
+ factsWithPreservedBlob.set(fact.id, blobData);
1642
2003
  if (!fact.deleted_at) preservedBlobDims.add(blobData.byteLength / 4);
1643
2004
  } else {
1644
2005
  await this.db.runAsync(
@@ -1648,6 +2009,7 @@ The following document anchors are provided for contradiction detection only. Do
1648
2009
  }
1649
2010
  existingFactsById.set(fact.id, { id: fact.id, entity_id: entityId, updated_at: safeUpdatedAt });
1650
2011
  upsertedFactIds.add(fact.id);
2012
+ if (fact.deleted_at) upsertedDeletedFactIds.add(fact.id);
1651
2013
  }
1652
2014
  }
1653
2015
  const taskIds = bundle.tasks.map((task) => task.id);
@@ -1703,12 +2065,34 @@ The following document anchors are provided for contradiction detection only. Do
1703
2065
  if (!fact.deleted_at && upsertedFactIds.has(fact.id) && !factsWithPreservedBlob.has(fact.id)) {
1704
2066
  await this.embedFact({
1705
2067
  id: fact.id,
2068
+ entity_id: entityId,
2069
+ // Use authoritative entityId from dump key, not fact.entity_id
1706
2070
  title: fact.title,
1707
2071
  body: fact.body,
1708
2072
  tags: Array.isArray(fact.tags) || typeof fact.tags === "string" ? fact.tags : []
1709
2073
  });
1710
2074
  }
1711
2075
  }
2076
+ for (const fact of bundle.facts) {
2077
+ const blobData = factsWithPreservedBlob.get(fact.id);
2078
+ if (blobData && !fact.deleted_at && upsertedFactIds.has(fact.id)) {
2079
+ try {
2080
+ const float32Vector = new Float32Array(blobData.buffer, blobData.byteOffset, blobData.byteLength / 4);
2081
+ await this._notifyEmbeddingPersisted(entityId, fact.id, float32Vector);
2082
+ } catch (hookErr) {
2083
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed for preserved-blob fact ${fact.id}:`, hookErr);
2084
+ }
2085
+ }
2086
+ }
2087
+ for (const factId of softDeletedFactIds) {
2088
+ if (!upsertedFactIds.has(factId) || upsertedDeletedFactIds.has(factId)) {
2089
+ try {
2090
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
2091
+ } catch (hookErr) {
2092
+ console.warn(`[WikiMemory] onEmbeddingPersisted(vector=null) hook failed for soft-deleted fact ${factId}:`, hookErr);
2093
+ }
2094
+ }
2095
+ }
1712
2096
  try {
1713
2097
  const canonicalRow = await this.db.getFirstAsync(
1714
2098
  `SELECT value FROM ${this.prefix}meta WHERE key = 'embedding_dimension'`
@@ -1779,7 +2163,13 @@ The following document anchors are provided for contradiction detection only. Do
1779
2163
  const now = Date.now();
1780
2164
  let deletedEntries = 0;
1781
2165
  let deletedTasks = 0;
2166
+ const deletedEntryIds = [];
1782
2167
  if (params.clearAll) {
2168
+ const entriesToDelete = await this.db.getAllAsync(
2169
+ `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
2170
+ [entityId]
2171
+ );
2172
+ deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
1783
2173
  const [entriesRes, tasksRes] = await Promise.all([
1784
2174
  this.db.runAsync(`UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`, [now, now, entityId]),
1785
2175
  this.db.runAsync(`UPDATE ${this.prefix}tasks SET deleted_at = ?, updated_at = ? WHERE entity_id = ? AND deleted_at IS NULL`, [now, now, entityId])
@@ -1797,6 +2187,27 @@ The following document anchors are provided for contradiction detection only. Do
1797
2187
  if (params.sourceRef !== void 0 && !sourceRef) throw new Error("Invalid sourceRef");
1798
2188
  const sourceHash = params.sourceHash !== void 0 ? normalizeSourceHash(params.sourceHash) : null;
1799
2189
  if (params.sourceHash !== void 0 && !sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
2190
+ if (params.entryId) {
2191
+ const entry = await this.db.getFirstAsync(
2192
+ `SELECT id FROM ${this.prefix}entries WHERE id = ? AND entity_id = ? AND deleted_at IS NULL`,
2193
+ [params.entryId, entityId]
2194
+ );
2195
+ if (entry) deletedEntryIds.push(entry.id);
2196
+ }
2197
+ if (sourceRef || sourceHash) {
2198
+ let q = `SELECT id FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`;
2199
+ const args = [entityId];
2200
+ if (sourceRef) {
2201
+ q += ` AND source_ref = ?`;
2202
+ args.push(sourceRef);
2203
+ }
2204
+ if (sourceHash) {
2205
+ q += ` AND source_hash = ?`;
2206
+ args.push(sourceHash);
2207
+ }
2208
+ const entriesToDelete = await this.db.getAllAsync(q, args);
2209
+ deletedEntryIds.push(...entriesToDelete.map((e) => e.id));
2210
+ }
1800
2211
  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;
1801
2212
  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;
1802
2213
  let refPromise = null;
@@ -1824,6 +2235,14 @@ The following document anchors are provided for contradiction detection only. Do
1824
2235
  }
1825
2236
  await this.rebuildMiniSearchIndex(entityId);
1826
2237
  this.vectorCache.delete(entityId);
2238
+ const uniqueDeletedIds = Array.from(new Set(deletedEntryIds));
2239
+ for (const factId of uniqueDeletedIds) {
2240
+ try {
2241
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
2242
+ } catch (hookErr) {
2243
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during forget for ${factId}:`, hookErr);
2244
+ }
2245
+ }
1827
2246
  return { deleted: { entries: deletedEntries, tasks: deletedTasks } };
1828
2247
  } finally {
1829
2248
  this.activeMaintenanceJobs.delete(forgetKey);
@@ -1893,7 +2312,15 @@ ${chunk}`;
1893
2312
  }
1894
2313
  const now = Date.now();
1895
2314
  const insertedFacts = [];
2315
+ const deletedSourceFactIds = [];
1896
2316
  await this.db.withTransactionAsync(async () => {
2317
+ const existingSourceFacts = await this.db.getAllAsync(
2318
+ `SELECT id FROM ${this.prefix}entries WHERE source_ref = ? AND entity_id = ? AND deleted_at IS NULL`,
2319
+ [sourceRef, entityId]
2320
+ );
2321
+ for (const row of existingSourceFacts) {
2322
+ deletedSourceFactIds.push(row.id);
2323
+ }
1897
2324
  await this.db.runAsync(
1898
2325
  `UPDATE ${this.prefix}entries SET deleted_at = ?, updated_at = ? WHERE source_ref = ? AND entity_id = ? AND deleted_at IS NULL`,
1899
2326
  [now, now, sourceRef, entityId]
@@ -1905,11 +2332,19 @@ ${chunk}`;
1905
2332
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1906
2333
  [id, entityId, fact.title, fact.body, JSON.stringify(fact.tags), fact.confidence, "user_document", sourceHash, sourceRef, now, now]
1907
2334
  );
1908
- insertedFacts.push({ id, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
2335
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1909
2336
  }
1910
2337
  });
1911
2338
  await this.rebuildMiniSearchIndex(entityId);
1912
2339
  this.vectorCache.delete(entityId);
2340
+ const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
2341
+ for (const factId of uniqueDeletedSourceFactIds) {
2342
+ try {
2343
+ await this._notifyEmbeddingPersisted(entityId, factId, null);
2344
+ } catch (hookErr) {
2345
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during ingest for ${factId}:`, hookErr);
2346
+ }
2347
+ }
1913
2348
  for (const fact of insertedFacts) {
1914
2349
  await this.embedFact(fact);
1915
2350
  }