@equationalapplications/core-llm-wiki 4.1.0 → 4.3.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
@@ -209,6 +209,40 @@ function parseEmbedding(blob, text) {
209
209
  return null;
210
210
  }
211
211
 
212
+ // src/readOptions.ts
213
+ function normalizeEntityIds(entityId) {
214
+ const input = Array.isArray(entityId) ? entityId : [entityId];
215
+ const seen = /* @__PURE__ */ new Set();
216
+ const normalized = [];
217
+ for (const id of input) {
218
+ if (seen.has(id)) continue;
219
+ seen.add(id);
220
+ normalized.push(id);
221
+ }
222
+ return normalized;
223
+ }
224
+ function sanitizeTierWeights(entityIds, tierWeights) {
225
+ if (tierWeights === void 0) return void 0;
226
+ const sanitized = /* @__PURE__ */ Object.create(null);
227
+ for (const entityId of entityIds) {
228
+ const raw = tierWeights[entityId];
229
+ if (raw === void 0 || !Number.isFinite(raw)) {
230
+ sanitized[entityId] = 1;
231
+ } else {
232
+ sanitized[entityId] = Math.max(0, raw);
233
+ }
234
+ }
235
+ return sanitized;
236
+ }
237
+ function applyTierWeight(score, entityId, sanitizedTierWeights) {
238
+ const weight = sanitizedTierWeights?.[entityId] ?? 1;
239
+ if (weight === 0) return -Infinity;
240
+ return score * weight;
241
+ }
242
+ function shouldExposeReadMetadata(entityId) {
243
+ return Array.isArray(entityId);
244
+ }
245
+
212
246
  // src/WikiMemory.ts
213
247
  var HOOK_TIMEOUT_MARKER = /* @__PURE__ */ Symbol("WikiMemoryHookTimeout");
214
248
  function parseJsonResponse(text) {
@@ -974,6 +1008,25 @@ After running the migration SQL, restart your application.`
974
1008
  }
975
1009
  async read(entityId, query, options) {
976
1010
  const config = this.options.config;
1011
+ const entityIds = normalizeEntityIds(entityId);
1012
+ const sanitizedTierWeights = sanitizeTierWeights(entityIds, options?.tierWeights);
1013
+ const exposeMetadata = shouldExposeReadMetadata(entityId);
1014
+ if (entityIds.length === 0) {
1015
+ const empty = { facts: [], tasks: [], events: [] };
1016
+ if (exposeMetadata) {
1017
+ empty.metadata = { query, entityIds: [] };
1018
+ if (sanitizedTierWeights && Object.keys(sanitizedTierWeights).length > 0) empty.metadata.tierWeights = sanitizedTierWeights;
1019
+ }
1020
+ return empty;
1021
+ }
1022
+ const MAX_ENTITY_IDS = 100;
1023
+ if (entityIds.length > MAX_ENTITY_IDS) {
1024
+ throw new RangeError(`read() accepts at most ${MAX_ENTITY_IDS} entity IDs; received ${entityIds.length}`);
1025
+ }
1026
+ const nullByteId = entityIds.find((id) => id.includes("\0"));
1027
+ if (nullByteId !== void 0) {
1028
+ throw new TypeError(`entity_id values must not contain the null byte (\\x00); got "${nullByteId}"`);
1029
+ }
977
1030
  const rawMaxResults = options?.maxResults ?? config?.maxResults ?? config?.maxFtsResults ?? 10;
978
1031
  const maxResults = Number.isFinite(rawMaxResults) ? Math.max(0, Math.trunc(rawMaxResults)) : 10;
979
1032
  const rawPreFilterLimit = options?.preFilterLimit === null ? void 0 : options?.preFilterLimit ?? config?.preFilterLimit;
@@ -984,13 +1037,15 @@ After running the migration SQL, restart your application.`
984
1037
  const embedFn = this.options.llmProvider.embed;
985
1038
  const trimmedQuery = query.trim();
986
1039
  let facts = [];
1040
+ let scoreByFactId;
987
1041
  if (maxResults === 0) ; else if (trimmedQuery) {
988
1042
  let usedEmbed = false;
989
- if (!skipEmbed && embedFn) {
1043
+ const scoredEntityIds = this._filterScoredEntities(entityIds, sanitizedTierWeights, options?.includeZeroWeightEntities);
1044
+ if (scoredEntityIds.length === 0) {
1045
+ usedEmbed = true;
1046
+ } else if (!skipEmbed && embedFn) {
990
1047
  let rankerShouldRethrow = false;
991
1048
  let pendingRankerFallbackError;
992
- let usedKeywordFallback = false;
993
- let scoredAlreadySortedAndLimited = false;
994
1049
  try {
995
1050
  const queryVec = await embedFn(trimmedQuery);
996
1051
  if (queryVec.length === 0 || !queryVec.every((v) => typeof v === "number" && isFinite(v))) {
@@ -1009,13 +1064,14 @@ After running the migration SQL, restart your application.`
1009
1064
  );
1010
1065
  }
1011
1066
  }
1067
+ const entityScope = this._entityInClause(scoredEntityIds);
1012
1068
  const mismatchedCount = await this.db.getFirstAsync(
1013
1069
  `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
1014
- WHERE entity_id = ? AND deleted_at IS NULL
1070
+ WHERE ${entityScope.clause} AND deleted_at IS NULL
1015
1071
  AND embedding_blob IS NOT NULL
1016
1072
  AND (CAST(length(embedding_blob) AS INTEGER) % 4 = 0)
1017
1073
  AND (CAST(length(embedding_blob) AS INTEGER) / 4) != ?`,
1018
- [entityId, queryVec.length]
1074
+ [...entityScope.params, queryVec.length]
1019
1075
  );
1020
1076
  if (mismatchedCount && mismatchedCount.cnt > 0) {
1021
1077
  throw new Error(
@@ -1024,12 +1080,13 @@ After running the migration SQL, restart your application.`
1024
1080
  }
1025
1081
  const useRanker = Boolean(this.options.vectorRanker);
1026
1082
  let candidateRows;
1027
- let populateCache = true;
1083
+ let populateCache = entityIds.length === 1;
1028
1084
  let miniSearchScores;
1029
1085
  if (effectivePreFilterLimit !== void 0) {
1030
1086
  populateCache = false;
1087
+ const entityIdSet = new Set(scoredEntityIds);
1031
1088
  const preResults = this.miniSearch.search(trimmedQuery, {
1032
- filter: (r) => r.entity_id === entityId,
1089
+ filter: (r) => entityIdSet.has(r.entity_id),
1033
1090
  combineWith: "OR"
1034
1091
  });
1035
1092
  if (preResults.length === 0) {
@@ -1047,7 +1104,7 @@ After running the migration SQL, restart your application.`
1047
1104
  const idChunk = topKIds.slice(i, i + inClauseChunkSize);
1048
1105
  const placeholders = idChunk.map(() => "?").join(",");
1049
1106
  const chunkRows = await this.db.getAllAsync(
1050
- `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1107
+ `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1051
1108
  idChunk
1052
1109
  );
1053
1110
  rows.push(...chunkRows);
@@ -1059,7 +1116,7 @@ After running the migration SQL, restart your application.`
1059
1116
  const idChunk = topKIds.slice(i, i + inClauseChunkSize);
1060
1117
  const placeholders = idChunk.map(() => "?").join(",");
1061
1118
  const chunkRows = await this.db.getAllAsync(
1062
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1119
+ `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1063
1120
  idChunk
1064
1121
  );
1065
1122
  rows.push(...chunkRows);
@@ -1074,19 +1131,22 @@ After running the migration SQL, restart your application.`
1074
1131
  }
1075
1132
  } else {
1076
1133
  if (useRanker) {
1134
+ const entityScope2 = this._entityInClause(scoredEntityIds);
1077
1135
  candidateRows = await this.db.getAllAsync(
1078
- `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
1079
- [entityId]
1136
+ `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE ${entityScope2.clause} AND deleted_at IS NULL`,
1137
+ entityScope2.params
1080
1138
  );
1081
1139
  } else {
1140
+ const entityScope2 = this._entityInClause(scoredEntityIds);
1082
1141
  candidateRows = await this.db.getAllAsync(
1083
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
1084
- [entityId]
1142
+ `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE ${entityScope2.clause} AND deleted_at IS NULL`,
1143
+ entityScope2.params
1085
1144
  );
1086
1145
  }
1087
1146
  if (weight !== void 0 && weight < 1) {
1147
+ const entityIdSet = new Set(scoredEntityIds);
1088
1148
  const msResults = this.miniSearch.search(trimmedQuery, {
1089
- filter: (r) => r.entity_id === entityId,
1149
+ filter: (r) => entityIdSet.has(r.entity_id),
1090
1150
  combineWith: "OR"
1091
1151
  });
1092
1152
  const maxMsScore = Math.max(1, msResults[0]?.score ?? 1);
@@ -1096,15 +1156,17 @@ After running the migration SQL, restart your application.`
1096
1156
  if (candidateRows === null) {
1097
1157
  usedEmbed = true;
1098
1158
  } else {
1159
+ const entityCacheKey = entityIds.length === 1 ? entityIds[0] : entityIds.join("\0");
1099
1160
  let scored;
1100
1161
  if (useRanker) {
1101
- const candidateIds = effectivePreFilterLimit !== void 0 ? candidateRows.map((r) => r.id) : void 0;
1162
+ const candidateIds = entityIds.length > 1 || effectivePreFilterLimit !== void 0 ? candidateRows.map((r) => r.id) : void 0;
1102
1163
  try {
1103
1164
  const oversampledLimit = Math.max(maxResults * 2, maxResults + 50);
1104
1165
  scored = await this._rankWithVectorRanker({
1105
- entityId,
1166
+ entityId: entityCacheKey,
1106
1167
  queryVec,
1107
1168
  candidateIds,
1169
+ candidateRows,
1108
1170
  weight,
1109
1171
  miniSearchScores,
1110
1172
  limit: oversampledLimit
@@ -1200,6 +1262,7 @@ After running the migration SQL, restart your application.`
1200
1262
  for (const { row, kwScore } of topK) {
1201
1263
  scored.push({
1202
1264
  id: row.id,
1265
+ entity_id: row.entity_id,
1203
1266
  score: (1 - weight) * kwScore,
1204
1267
  updated_at: row.updated_at,
1205
1268
  access_count: row.access_count
@@ -1209,7 +1272,7 @@ After running the migration SQL, restart your application.`
1209
1272
  const omitted = [];
1210
1273
  for (const row of candidateRows) {
1211
1274
  if (scoredIds.has(row.id)) continue;
1212
- omitted.push({ id: row.id, score: -2, updated_at: row.updated_at, access_count: row.access_count });
1275
+ omitted.push({ id: row.id, entity_id: row.entity_id, score: -2, updated_at: row.updated_at, access_count: row.access_count });
1213
1276
  }
1214
1277
  if (omitted.length > 0) {
1215
1278
  this._tieBreakSort(omitted);
@@ -1237,8 +1300,8 @@ After running the migration SQL, restart your application.`
1237
1300
  const idChunk = rowIds.slice(i, i + chunkSize);
1238
1301
  const placeholders = idChunk.map(() => "?").join(",");
1239
1302
  const embeddingRows = await this.db.getAllAsync(
1240
- `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1241
- [...idChunk, entityId]
1303
+ `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1304
+ idChunk
1242
1305
  );
1243
1306
  for (const row of embeddingRows) {
1244
1307
  embeddingsMap.set(row.id, { embedding_blob: row.embedding_blob, embedding: row.embedding });
@@ -1251,38 +1314,41 @@ After running the migration SQL, restart your application.`
1251
1314
  }));
1252
1315
  }
1253
1316
  scored = await this._rankWithJsCosine({
1254
- entityId,
1317
+ entityId: entityCacheKey,
1255
1318
  queryVec,
1256
1319
  candidateRows: fallbackRows,
1257
1320
  weight,
1258
1321
  miniSearchScores,
1259
1322
  populateCache,
1260
- limit: maxResults
1323
+ limit: fallbackRows.length,
1324
+ skipSort: true
1325
+ // read() re-sorts after applying tier weights
1261
1326
  });
1262
- scoredAlreadySortedAndLimited = true;
1263
1327
  } else if (policy === "keyword") {
1328
+ const scoredEntityIdSet = new Set(scoredEntityIds);
1264
1329
  const msResults = this.miniSearch.search(trimmedQuery, {
1265
- filter: (r) => r.entity_id === entityId,
1330
+ filter: (r) => scoredEntityIdSet.has(r.entity_id),
1266
1331
  combineWith: "OR"
1267
1332
  });
1268
- const topResults = msResults.slice(0, maxResults);
1333
+ const keywordOversampledLimit = Math.max(maxResults * 2, maxResults + 50);
1334
+ const topResults = msResults.slice(0, keywordOversampledLimit);
1269
1335
  const resultIds = new Set(topResults.map((r) => r.id));
1270
1336
  const candidateMap = /* @__PURE__ */ new Map();
1271
1337
  for (const r of candidateRows) {
1272
1338
  if (resultIds.has(r.id)) {
1273
- candidateMap.set(r.id, { updated_at: r.updated_at, access_count: r.access_count });
1339
+ candidateMap.set(r.id, { entity_id: r.entity_id, updated_at: r.updated_at, access_count: r.access_count });
1274
1340
  }
1275
1341
  }
1276
1342
  scored = topResults.map((r) => {
1277
1343
  const meta = candidateMap.get(r.id);
1278
1344
  return {
1279
1345
  id: r.id,
1346
+ entity_id: meta?.entity_id ?? r.entity_id,
1280
1347
  score: r.score ?? 0,
1281
1348
  access_count: meta?.access_count ?? null,
1282
1349
  updated_at: meta?.updated_at ?? null
1283
1350
  };
1284
1351
  });
1285
- usedKeywordFallback = true;
1286
1352
  } else {
1287
1353
  scored = [];
1288
1354
  }
@@ -1294,46 +1360,44 @@ After running the migration SQL, restart your application.`
1294
1360
  }
1295
1361
  }
1296
1362
  } else {
1363
+ const jsCosineNeedsTierSort = sanitizedTierWeights !== void 0 && Object.values(sanitizedTierWeights).some((w) => w !== 1);
1297
1364
  scored = await this._rankWithJsCosine({
1298
- entityId,
1365
+ entityId: entityCacheKey,
1299
1366
  queryVec,
1300
1367
  candidateRows,
1301
1368
  weight,
1302
1369
  miniSearchScores,
1303
1370
  populateCache,
1304
- limit: maxResults
1371
+ limit: jsCosineNeedsTierSort ? candidateRows.length : maxResults,
1372
+ skipSort: jsCosineNeedsTierSort
1373
+ // read() re-sorts after applying tier weights
1305
1374
  });
1306
- scoredAlreadySortedAndLimited = true;
1307
1375
  }
1308
1376
  if (scored.length > 0) {
1309
- if (!usedKeywordFallback && !scoredAlreadySortedAndLimited) {
1310
- this._tieBreakSort(scored);
1377
+ scored = scored.map((row) => ({
1378
+ ...row,
1379
+ score: applyTierWeight(row.score, row.entity_id, sanitizedTierWeights)
1380
+ }));
1381
+ this._tieBreakSort(scored);
1382
+ const selectedScored = scored.slice(0, maxResults);
1383
+ const topIds = selectedScored.map((s) => s.id);
1384
+ if (exposeMetadata && trimmedQuery) {
1385
+ scoreByFactId = new Map(selectedScored.map((s) => [s.id, Number.isFinite(s.score) ? s.score : 0]));
1311
1386
  }
1312
- const topIds = (scoredAlreadySortedAndLimited ? scored : scored.slice(0, maxResults)).map((s) => s.id);
1313
1387
  if (topIds.length > 0) {
1314
- const fullRows = [];
1315
- const phase2ChunkSize = 500;
1316
- for (let i = 0; i < topIds.length; i += phase2ChunkSize) {
1317
- const idChunk = topIds.slice(i, i + phase2ChunkSize);
1318
- const placeholders = idChunk.map(() => "?").join(",");
1319
- const chunkRows = await this.db.getAllAsync(
1320
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1321
- [...idChunk, entityId]
1322
- );
1323
- fullRows.push(...chunkRows);
1324
- }
1325
- const byId = new Map(fullRows.map((r) => [r.id, r]));
1326
- facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1327
- if (facts.length < topIds.length) {
1328
- const missingIds = topIds.filter((id) => !byId.has(id));
1388
+ const facts2 = await this._hydrateFactsByIds(topIds, entityIds);
1389
+ if (facts2.length < topIds.length) {
1390
+ const hydrationById = new Set(facts2.map((f) => f.id));
1391
+ const missingIds = topIds.filter((id) => !hydrationById.has(id));
1329
1392
  const missingCount = missingIds.length;
1330
1393
  const sample = missingIds.slice(0, 5);
1331
1394
  const sampleSuffix = sample.length > 0 ? ` Missing ID sample: ${sample.join(", ")}${missingIds.length > sample.length ? ", ..." : ""}.` : "";
1332
1395
  const error = new Error(
1333
- `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
1396
+ `Phase 2 fact hydration returned ${missingCount} fewer row(s) than ranked IDs. 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 in requested entities.` + sampleSuffix
1334
1397
  );
1335
1398
  this.options.onRetrievalFallback?.(error);
1336
1399
  }
1400
+ facts = facts2;
1337
1401
  }
1338
1402
  if (pendingRankerFallbackError) {
1339
1403
  this.options.onRetrievalFallback?.(pendingRankerFallbackError);
@@ -1360,26 +1424,28 @@ After running the migration SQL, restart your application.`
1360
1424
  this.options.onRetrievalFallback?.(error);
1361
1425
  }
1362
1426
  }
1363
- if (!usedEmbed) {
1427
+ if (!usedEmbed && scoredEntityIds.length > 0) {
1428
+ const fallbackEntityIdSet = new Set(scoredEntityIds);
1429
+ const fallbackOversampledLimit = Math.max(maxResults * 2, maxResults + 50);
1364
1430
  const results = this.miniSearch.search(trimmedQuery, {
1365
- filter: (r) => r.entity_id === entityId,
1431
+ filter: (r) => fallbackEntityIdSet.has(r.entity_id),
1366
1432
  combineWith: "OR"
1367
1433
  });
1368
- const topIds = results.slice(0, maxResults).map((r) => r.id);
1434
+ const candidates = results.slice(0, fallbackOversampledLimit).map((r) => ({
1435
+ id: r.id,
1436
+ entity_id: r.entity_id,
1437
+ score: applyTierWeight(r.score ?? 0, r.entity_id, sanitizedTierWeights),
1438
+ updated_at: null,
1439
+ access_count: null
1440
+ }));
1441
+ this._tieBreakSort(candidates);
1442
+ const topCandidates = candidates.slice(0, maxResults);
1443
+ const topIds = topCandidates.map((c) => c.id);
1369
1444
  if (topIds.length > 0) {
1370
- const kwRows = [];
1371
- const kwChunkSize = 500;
1372
- for (let i = 0; i < topIds.length; i += kwChunkSize) {
1373
- const idChunk = topIds.slice(i, i + kwChunkSize);
1374
- const placeholders = idChunk.map(() => "?").join(",");
1375
- const chunkRows = await this.db.getAllAsync(
1376
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1377
- [...idChunk, entityId]
1378
- );
1379
- kwRows.push(...chunkRows);
1445
+ facts = await this._hydrateFactsByIds(topIds, entityIds);
1446
+ if (exposeMetadata) {
1447
+ scoreByFactId = new Map(topCandidates.map((c) => [c.id, Number.isFinite(c.score) ? c.score : 0]));
1380
1448
  }
1381
- const byId = new Map(kwRows.map((r) => [r.id, r]));
1382
- facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1383
1449
  }
1384
1450
  }
1385
1451
  if (facts.length > 0) {
@@ -1398,37 +1464,66 @@ After running the migration SQL, restart your application.`
1398
1464
  }
1399
1465
  }
1400
1466
  } else {
1401
- facts = await this.db.getAllAsync(
1467
+ const entityScope = this._entityInClause(entityIds);
1468
+ const rawFacts = await this.db.getAllAsync(
1402
1469
  `SELECT * FROM ${this.prefix}entries
1403
- WHERE entity_id = ? AND deleted_at IS NULL
1470
+ WHERE ${entityScope.clause} AND deleted_at IS NULL
1404
1471
  ORDER BY updated_at DESC
1405
1472
  LIMIT ?`,
1406
- [entityId, maxResults]
1473
+ [...entityScope.params, maxResults]
1407
1474
  );
1475
+ facts = rawFacts.map((f) => {
1476
+ const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
1477
+ return {
1478
+ ...rest,
1479
+ tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1480
+ };
1481
+ });
1408
1482
  }
1409
1483
  const [tasks, events] = await Promise.all([
1410
- this.db.getAllAsync(
1411
- `SELECT * FROM ${this.prefix}tasks
1412
- WHERE entity_id = ? AND status IN ('pending', 'in_progress') AND deleted_at IS NULL
1413
- ORDER BY priority DESC, created_at ASC`,
1414
- [entityId]
1415
- ),
1416
- this.db.getAllAsync(
1417
- `SELECT * FROM ${this.prefix}events
1418
- WHERE entity_id = ?
1419
- ORDER BY created_at DESC
1420
- LIMIT 10`,
1421
- [entityId]
1422
- )
1484
+ (async () => {
1485
+ const entityScope = this._entityInClause(entityIds);
1486
+ const tasksLimit = entityIds.length === 1 ? void 0 : Math.min(20 * entityIds.length, 200);
1487
+ return this.db.getAllAsync(
1488
+ `SELECT * FROM ${this.prefix}tasks
1489
+ WHERE ${entityScope.clause} AND status IN ('pending', 'in_progress') AND deleted_at IS NULL
1490
+ ORDER BY priority DESC, created_at ASC${tasksLimit !== void 0 ? "\n LIMIT ?" : ""}`,
1491
+ tasksLimit !== void 0 ? [...entityScope.params, tasksLimit] : entityScope.params
1492
+ );
1493
+ })(),
1494
+ (async () => {
1495
+ const entityScope = this._entityInClause(entityIds);
1496
+ const eventsLimit = Math.min(10 * entityIds.length, 100);
1497
+ return this.db.getAllAsync(
1498
+ `SELECT * FROM ${this.prefix}events
1499
+ WHERE ${entityScope.clause}
1500
+ ORDER BY created_at DESC
1501
+ LIMIT ?`,
1502
+ [...entityScope.params, eventsLimit]
1503
+ );
1504
+ })()
1423
1505
  ]);
1424
- const parsedFacts = facts.map((f) => {
1425
- const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
1426
- return {
1427
- ...rest,
1428
- tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1429
- };
1506
+ let factScores;
1507
+ if (exposeMetadata && trimmedQuery && scoreByFactId) {
1508
+ factScores = Object.fromEntries(facts.map((fact) => [fact.id, scoreByFactId.get(fact.id) ?? 0]));
1509
+ }
1510
+ const bundle = { facts, tasks, events: events.reverse() };
1511
+ if (exposeMetadata) {
1512
+ bundle.metadata = { query, entityIds };
1513
+ if (sanitizedTierWeights && Object.keys(sanitizedTierWeights).length > 0) bundle.metadata.tierWeights = sanitizedTierWeights;
1514
+ if (factScores && Object.keys(factScores).length > 0) bundle.factScores = factScores;
1515
+ }
1516
+ return bundle;
1517
+ }
1518
+ /**
1519
+ * Returns entity IDs that will participate in scored retrieval.
1520
+ * Excludes zero-weight entities unless includeZeroWeightEntities is true.
1521
+ */
1522
+ _filterScoredEntities(entityIds, sanitizedTierWeights, includeZeroWeightEntities) {
1523
+ return entityIds.filter((id) => {
1524
+ const w = sanitizedTierWeights?.[id] ?? 1;
1525
+ return includeZeroWeightEntities === true || w !== 0;
1430
1526
  });
1431
- return { facts: parsedFacts, tasks, events: events.reverse() };
1432
1527
  }
1433
1528
  /**
1434
1529
  * Stable tie-break sort: score desc → access_count desc → updated_at desc → id asc.
@@ -1442,13 +1537,48 @@ After running the migration SQL, restart your application.`
1442
1537
  */
1443
1538
  _compareScoredRows(a, b) {
1444
1539
  const scoreDiff = b.score - a.score;
1445
- if (scoreDiff !== 0) return scoreDiff;
1540
+ if (!Number.isNaN(scoreDiff) && scoreDiff !== 0) return scoreDiff;
1446
1541
  const accessCountDiff = (b.access_count ?? 0) - (a.access_count ?? 0);
1447
1542
  if (accessCountDiff !== 0) return accessCountDiff;
1448
1543
  const updatedAtDiff = (b.updated_at ?? 0) - (a.updated_at ?? 0);
1449
1544
  if (updatedAtDiff !== 0) return updatedAtDiff;
1450
1545
  return a.id.localeCompare(b.id);
1451
1546
  }
1547
+ /**
1548
+ * Build SQL IN clause with placeholders for multiple entity IDs.
1549
+ */
1550
+ _entityInClause(entityIds) {
1551
+ if (entityIds.length === 0) return { clause: "1=0", params: [] };
1552
+ const placeholders = entityIds.map(() => "?").join(",");
1553
+ return { clause: `entity_id IN (${placeholders})`, params: [...entityIds] };
1554
+ }
1555
+ /**
1556
+ * Hydrate full facts by ID. Pass scopedEntityIds to restrict to requested namespaces in SQL
1557
+ * (defense-in-depth against a rogue VectorRanker returning cross-entity IDs).
1558
+ */
1559
+ async _hydrateFactsByIds(ids, scopedEntityIds) {
1560
+ const fullRows = [];
1561
+ const chunkSize = 500;
1562
+ const entityClause = scopedEntityIds && scopedEntityIds.length > 0 ? ` AND entity_id IN (${scopedEntityIds.map(() => "?").join(",")})` : "";
1563
+ const entityParams = scopedEntityIds && scopedEntityIds.length > 0 ? [...scopedEntityIds] : [];
1564
+ for (let i = 0; i < ids.length; i += chunkSize) {
1565
+ const idChunk = ids.slice(i, i + chunkSize);
1566
+ const placeholders = idChunk.map(() => "?").join(",");
1567
+ const chunkRows = await this.db.getAllAsync(
1568
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders})${entityClause} AND deleted_at IS NULL`,
1569
+ [...idChunk, ...entityParams]
1570
+ );
1571
+ fullRows.push(...chunkRows);
1572
+ }
1573
+ const byId = new Map(fullRows.map((row) => [row.id, row]));
1574
+ return ids.map((id) => byId.get(id)).filter((fact) => fact !== void 0).map((fact) => {
1575
+ const { embedding: _embedding, embedding_blob: _blob, ...rest } = fact;
1576
+ return {
1577
+ ...rest,
1578
+ tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1579
+ };
1580
+ });
1581
+ }
1452
1582
  /**
1453
1583
  * Strip potentially sensitive data from ranker errors before exposing to host callbacks.
1454
1584
  * Preserves error type for debugging but removes message/stack that may contain credentials.
@@ -1473,7 +1603,7 @@ After running the migration SQL, restart your application.`
1473
1603
  */
1474
1604
  async _rankWithJsCosine(args) {
1475
1605
  const queryVec = args.queryVec instanceof Float32Array ? args.queryVec.slice() : Array.from(args.queryVec);
1476
- const { entityId, candidateRows, weight, miniSearchScores, populateCache, limit } = args;
1606
+ const { entityId, candidateRows, weight, miniSearchScores, populateCache, limit, skipSort } = args;
1477
1607
  let entityCache = this.vectorCache.get(entityId);
1478
1608
  const tooLarge = populateCache && candidateRows.length > _WikiMemory.MAX_VECTOR_CACHE_FACTS_PER_ENTITY;
1479
1609
  if (tooLarge && entityCache) {
@@ -1504,7 +1634,13 @@ After running the migration SQL, restart your application.`
1504
1634
  } else {
1505
1635
  score = -2;
1506
1636
  }
1507
- return { id: row.id, score, updated_at: row.updated_at, access_count: row.access_count };
1637
+ return {
1638
+ id: row.id,
1639
+ entity_id: row.entity_id,
1640
+ score,
1641
+ updated_at: row.updated_at,
1642
+ access_count: row.access_count
1643
+ };
1508
1644
  });
1509
1645
  if (canCache && entityCache && entityCache.size > 0) {
1510
1646
  if (!this.vectorCache.has(entityId)) {
@@ -1515,7 +1651,7 @@ After running the migration SQL, restart your application.`
1515
1651
  this.vectorCache.set(entityId, entityCache);
1516
1652
  }
1517
1653
  }
1518
- this._tieBreakSort(scored);
1654
+ if (!skipSort) this._tieBreakSort(scored);
1519
1655
  return scored.slice(0, limit);
1520
1656
  }
1521
1657
  /**
@@ -1524,7 +1660,7 @@ After running the migration SQL, restart your application.`
1524
1660
  * Returns scored results ready for hybrid blending and tie-break sorting.
1525
1661
  */
1526
1662
  async _rankWithVectorRanker(args) {
1527
- const { entityId, candidateIds, weight, miniSearchScores, limit } = args;
1663
+ const { entityId, candidateIds, candidateRows, weight, miniSearchScores, limit } = args;
1528
1664
  const ranker = this.options.vectorRanker;
1529
1665
  if (!ranker) {
1530
1666
  throw new Error("vectorRanker not configured");
@@ -1536,7 +1672,7 @@ After running the migration SQL, restart your application.`
1536
1672
  candidateIds,
1537
1673
  limit
1538
1674
  });
1539
- const allowedIds = candidateIds ? new Set(candidateIds) : void 0;
1675
+ const allowedIds = new Set(candidateRows.map((row) => row.id));
1540
1676
  const seen = /* @__PURE__ */ new Set();
1541
1677
  const normalized = [];
1542
1678
  for (const r of rankerResults) {
@@ -1547,13 +1683,19 @@ After running the migration SQL, restart your application.`
1547
1683
  seen.add(r.id);
1548
1684
  normalized.push(r);
1549
1685
  }
1686
+ const entityIdByCandidateId = new Map(candidateRows.map((row) => [row.id, row.entity_id]));
1550
1687
  const scored = normalized.map((r) => {
1551
1688
  let score = r.semanticScore;
1552
1689
  if (weight !== void 0) {
1553
1690
  const kwScore = miniSearchScores?.get(r.id) ?? 0;
1554
1691
  score = weight * Math.max(0, r.semanticScore) + (1 - weight) * kwScore;
1555
1692
  }
1556
- return { id: r.id, score };
1693
+ return {
1694
+ id: r.id,
1695
+ entity_id: entityIdByCandidateId.get(r.id),
1696
+ // allowedIds filter above guarantees membership
1697
+ score
1698
+ };
1557
1699
  });
1558
1700
  return scored;
1559
1701
  }