@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.mjs CHANGED
@@ -203,6 +203,40 @@ function parseEmbedding(blob, text) {
203
203
  return null;
204
204
  }
205
205
 
206
+ // src/readOptions.ts
207
+ function normalizeEntityIds(entityId) {
208
+ const input = Array.isArray(entityId) ? entityId : [entityId];
209
+ const seen = /* @__PURE__ */ new Set();
210
+ const normalized = [];
211
+ for (const id of input) {
212
+ if (seen.has(id)) continue;
213
+ seen.add(id);
214
+ normalized.push(id);
215
+ }
216
+ return normalized;
217
+ }
218
+ function sanitizeTierWeights(entityIds, tierWeights) {
219
+ if (tierWeights === void 0) return void 0;
220
+ const sanitized = /* @__PURE__ */ Object.create(null);
221
+ for (const entityId of entityIds) {
222
+ const raw = tierWeights[entityId];
223
+ if (raw === void 0 || !Number.isFinite(raw)) {
224
+ sanitized[entityId] = 1;
225
+ } else {
226
+ sanitized[entityId] = Math.max(0, raw);
227
+ }
228
+ }
229
+ return sanitized;
230
+ }
231
+ function applyTierWeight(score, entityId, sanitizedTierWeights) {
232
+ const weight = sanitizedTierWeights?.[entityId] ?? 1;
233
+ if (weight === 0) return -Infinity;
234
+ return score * weight;
235
+ }
236
+ function shouldExposeReadMetadata(entityId) {
237
+ return Array.isArray(entityId);
238
+ }
239
+
206
240
  // src/WikiMemory.ts
207
241
  var HOOK_TIMEOUT_MARKER = /* @__PURE__ */ Symbol("WikiMemoryHookTimeout");
208
242
  function parseJsonResponse(text) {
@@ -968,6 +1002,25 @@ After running the migration SQL, restart your application.`
968
1002
  }
969
1003
  async read(entityId, query, options) {
970
1004
  const config = this.options.config;
1005
+ const entityIds = normalizeEntityIds(entityId);
1006
+ const sanitizedTierWeights = sanitizeTierWeights(entityIds, options?.tierWeights);
1007
+ const exposeMetadata = shouldExposeReadMetadata(entityId);
1008
+ if (entityIds.length === 0) {
1009
+ const empty = { facts: [], tasks: [], events: [] };
1010
+ if (exposeMetadata) {
1011
+ empty.metadata = { query, entityIds: [] };
1012
+ if (sanitizedTierWeights && Object.keys(sanitizedTierWeights).length > 0) empty.metadata.tierWeights = sanitizedTierWeights;
1013
+ }
1014
+ return empty;
1015
+ }
1016
+ const MAX_ENTITY_IDS = 100;
1017
+ if (entityIds.length > MAX_ENTITY_IDS) {
1018
+ throw new RangeError(`read() accepts at most ${MAX_ENTITY_IDS} entity IDs; received ${entityIds.length}`);
1019
+ }
1020
+ const nullByteId = entityIds.find((id) => id.includes("\0"));
1021
+ if (nullByteId !== void 0) {
1022
+ throw new TypeError(`entity_id values must not contain the null byte (\\x00); got "${nullByteId}"`);
1023
+ }
971
1024
  const rawMaxResults = options?.maxResults ?? config?.maxResults ?? config?.maxFtsResults ?? 10;
972
1025
  const maxResults = Number.isFinite(rawMaxResults) ? Math.max(0, Math.trunc(rawMaxResults)) : 10;
973
1026
  const rawPreFilterLimit = options?.preFilterLimit === null ? void 0 : options?.preFilterLimit ?? config?.preFilterLimit;
@@ -978,13 +1031,15 @@ After running the migration SQL, restart your application.`
978
1031
  const embedFn = this.options.llmProvider.embed;
979
1032
  const trimmedQuery = query.trim();
980
1033
  let facts = [];
1034
+ let scoreByFactId;
981
1035
  if (maxResults === 0) ; else if (trimmedQuery) {
982
1036
  let usedEmbed = false;
983
- if (!skipEmbed && embedFn) {
1037
+ const scoredEntityIds = this._filterScoredEntities(entityIds, sanitizedTierWeights, options?.includeZeroWeightEntities);
1038
+ if (scoredEntityIds.length === 0) {
1039
+ usedEmbed = true;
1040
+ } else if (!skipEmbed && embedFn) {
984
1041
  let rankerShouldRethrow = false;
985
1042
  let pendingRankerFallbackError;
986
- let usedKeywordFallback = false;
987
- let scoredAlreadySortedAndLimited = false;
988
1043
  try {
989
1044
  const queryVec = await embedFn(trimmedQuery);
990
1045
  if (queryVec.length === 0 || !queryVec.every((v) => typeof v === "number" && isFinite(v))) {
@@ -1003,13 +1058,14 @@ After running the migration SQL, restart your application.`
1003
1058
  );
1004
1059
  }
1005
1060
  }
1061
+ const entityScope = this._entityInClause(scoredEntityIds);
1006
1062
  const mismatchedCount = await this.db.getFirstAsync(
1007
1063
  `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
1008
- WHERE entity_id = ? AND deleted_at IS NULL
1064
+ WHERE ${entityScope.clause} AND deleted_at IS NULL
1009
1065
  AND embedding_blob IS NOT NULL
1010
1066
  AND (CAST(length(embedding_blob) AS INTEGER) % 4 = 0)
1011
1067
  AND (CAST(length(embedding_blob) AS INTEGER) / 4) != ?`,
1012
- [entityId, queryVec.length]
1068
+ [...entityScope.params, queryVec.length]
1013
1069
  );
1014
1070
  if (mismatchedCount && mismatchedCount.cnt > 0) {
1015
1071
  throw new Error(
@@ -1018,12 +1074,13 @@ After running the migration SQL, restart your application.`
1018
1074
  }
1019
1075
  const useRanker = Boolean(this.options.vectorRanker);
1020
1076
  let candidateRows;
1021
- let populateCache = true;
1077
+ let populateCache = entityIds.length === 1;
1022
1078
  let miniSearchScores;
1023
1079
  if (effectivePreFilterLimit !== void 0) {
1024
1080
  populateCache = false;
1081
+ const entityIdSet = new Set(scoredEntityIds);
1025
1082
  const preResults = this.miniSearch.search(trimmedQuery, {
1026
- filter: (r) => r.entity_id === entityId,
1083
+ filter: (r) => entityIdSet.has(r.entity_id),
1027
1084
  combineWith: "OR"
1028
1085
  });
1029
1086
  if (preResults.length === 0) {
@@ -1041,7 +1098,7 @@ After running the migration SQL, restart your application.`
1041
1098
  const idChunk = topKIds.slice(i, i + inClauseChunkSize);
1042
1099
  const placeholders = idChunk.map(() => "?").join(",");
1043
1100
  const chunkRows = await this.db.getAllAsync(
1044
- `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1101
+ `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1045
1102
  idChunk
1046
1103
  );
1047
1104
  rows.push(...chunkRows);
@@ -1053,7 +1110,7 @@ After running the migration SQL, restart your application.`
1053
1110
  const idChunk = topKIds.slice(i, i + inClauseChunkSize);
1054
1111
  const placeholders = idChunk.map(() => "?").join(",");
1055
1112
  const chunkRows = await this.db.getAllAsync(
1056
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1113
+ `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1057
1114
  idChunk
1058
1115
  );
1059
1116
  rows.push(...chunkRows);
@@ -1068,19 +1125,22 @@ After running the migration SQL, restart your application.`
1068
1125
  }
1069
1126
  } else {
1070
1127
  if (useRanker) {
1128
+ const entityScope2 = this._entityInClause(scoredEntityIds);
1071
1129
  candidateRows = await this.db.getAllAsync(
1072
- `SELECT id, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
1073
- [entityId]
1130
+ `SELECT id, entity_id, updated_at, access_count FROM ${this.prefix}entries WHERE ${entityScope2.clause} AND deleted_at IS NULL`,
1131
+ entityScope2.params
1074
1132
  );
1075
1133
  } else {
1134
+ const entityScope2 = this._entityInClause(scoredEntityIds);
1076
1135
  candidateRows = await this.db.getAllAsync(
1077
- `SELECT id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE entity_id = ? AND deleted_at IS NULL`,
1078
- [entityId]
1136
+ `SELECT id, entity_id, embedding_blob, embedding, updated_at, access_count FROM ${this.prefix}entries WHERE ${entityScope2.clause} AND deleted_at IS NULL`,
1137
+ entityScope2.params
1079
1138
  );
1080
1139
  }
1081
1140
  if (weight !== void 0 && weight < 1) {
1141
+ const entityIdSet = new Set(scoredEntityIds);
1082
1142
  const msResults = this.miniSearch.search(trimmedQuery, {
1083
- filter: (r) => r.entity_id === entityId,
1143
+ filter: (r) => entityIdSet.has(r.entity_id),
1084
1144
  combineWith: "OR"
1085
1145
  });
1086
1146
  const maxMsScore = Math.max(1, msResults[0]?.score ?? 1);
@@ -1090,15 +1150,17 @@ After running the migration SQL, restart your application.`
1090
1150
  if (candidateRows === null) {
1091
1151
  usedEmbed = true;
1092
1152
  } else {
1153
+ const entityCacheKey = entityIds.length === 1 ? entityIds[0] : entityIds.join("\0");
1093
1154
  let scored;
1094
1155
  if (useRanker) {
1095
- const candidateIds = effectivePreFilterLimit !== void 0 ? candidateRows.map((r) => r.id) : void 0;
1156
+ const candidateIds = entityIds.length > 1 || effectivePreFilterLimit !== void 0 ? candidateRows.map((r) => r.id) : void 0;
1096
1157
  try {
1097
1158
  const oversampledLimit = Math.max(maxResults * 2, maxResults + 50);
1098
1159
  scored = await this._rankWithVectorRanker({
1099
- entityId,
1160
+ entityId: entityCacheKey,
1100
1161
  queryVec,
1101
1162
  candidateIds,
1163
+ candidateRows,
1102
1164
  weight,
1103
1165
  miniSearchScores,
1104
1166
  limit: oversampledLimit
@@ -1194,6 +1256,7 @@ After running the migration SQL, restart your application.`
1194
1256
  for (const { row, kwScore } of topK) {
1195
1257
  scored.push({
1196
1258
  id: row.id,
1259
+ entity_id: row.entity_id,
1197
1260
  score: (1 - weight) * kwScore,
1198
1261
  updated_at: row.updated_at,
1199
1262
  access_count: row.access_count
@@ -1203,7 +1266,7 @@ After running the migration SQL, restart your application.`
1203
1266
  const omitted = [];
1204
1267
  for (const row of candidateRows) {
1205
1268
  if (scoredIds.has(row.id)) continue;
1206
- omitted.push({ id: row.id, score: -2, updated_at: row.updated_at, access_count: row.access_count });
1269
+ omitted.push({ id: row.id, entity_id: row.entity_id, score: -2, updated_at: row.updated_at, access_count: row.access_count });
1207
1270
  }
1208
1271
  if (omitted.length > 0) {
1209
1272
  this._tieBreakSort(omitted);
@@ -1231,8 +1294,8 @@ After running the migration SQL, restart your application.`
1231
1294
  const idChunk = rowIds.slice(i, i + chunkSize);
1232
1295
  const placeholders = idChunk.map(() => "?").join(",");
1233
1296
  const embeddingRows = await this.db.getAllAsync(
1234
- `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1235
- [...idChunk, entityId]
1297
+ `SELECT id, embedding_blob, embedding FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
1298
+ idChunk
1236
1299
  );
1237
1300
  for (const row of embeddingRows) {
1238
1301
  embeddingsMap.set(row.id, { embedding_blob: row.embedding_blob, embedding: row.embedding });
@@ -1245,38 +1308,41 @@ After running the migration SQL, restart your application.`
1245
1308
  }));
1246
1309
  }
1247
1310
  scored = await this._rankWithJsCosine({
1248
- entityId,
1311
+ entityId: entityCacheKey,
1249
1312
  queryVec,
1250
1313
  candidateRows: fallbackRows,
1251
1314
  weight,
1252
1315
  miniSearchScores,
1253
1316
  populateCache,
1254
- limit: maxResults
1317
+ limit: fallbackRows.length,
1318
+ skipSort: true
1319
+ // read() re-sorts after applying tier weights
1255
1320
  });
1256
- scoredAlreadySortedAndLimited = true;
1257
1321
  } else if (policy === "keyword") {
1322
+ const scoredEntityIdSet = new Set(scoredEntityIds);
1258
1323
  const msResults = this.miniSearch.search(trimmedQuery, {
1259
- filter: (r) => r.entity_id === entityId,
1324
+ filter: (r) => scoredEntityIdSet.has(r.entity_id),
1260
1325
  combineWith: "OR"
1261
1326
  });
1262
- const topResults = msResults.slice(0, maxResults);
1327
+ const keywordOversampledLimit = Math.max(maxResults * 2, maxResults + 50);
1328
+ const topResults = msResults.slice(0, keywordOversampledLimit);
1263
1329
  const resultIds = new Set(topResults.map((r) => r.id));
1264
1330
  const candidateMap = /* @__PURE__ */ new Map();
1265
1331
  for (const r of candidateRows) {
1266
1332
  if (resultIds.has(r.id)) {
1267
- candidateMap.set(r.id, { updated_at: r.updated_at, access_count: r.access_count });
1333
+ candidateMap.set(r.id, { entity_id: r.entity_id, updated_at: r.updated_at, access_count: r.access_count });
1268
1334
  }
1269
1335
  }
1270
1336
  scored = topResults.map((r) => {
1271
1337
  const meta = candidateMap.get(r.id);
1272
1338
  return {
1273
1339
  id: r.id,
1340
+ entity_id: meta?.entity_id ?? r.entity_id,
1274
1341
  score: r.score ?? 0,
1275
1342
  access_count: meta?.access_count ?? null,
1276
1343
  updated_at: meta?.updated_at ?? null
1277
1344
  };
1278
1345
  });
1279
- usedKeywordFallback = true;
1280
1346
  } else {
1281
1347
  scored = [];
1282
1348
  }
@@ -1288,46 +1354,44 @@ After running the migration SQL, restart your application.`
1288
1354
  }
1289
1355
  }
1290
1356
  } else {
1357
+ const jsCosineNeedsTierSort = sanitizedTierWeights !== void 0 && Object.values(sanitizedTierWeights).some((w) => w !== 1);
1291
1358
  scored = await this._rankWithJsCosine({
1292
- entityId,
1359
+ entityId: entityCacheKey,
1293
1360
  queryVec,
1294
1361
  candidateRows,
1295
1362
  weight,
1296
1363
  miniSearchScores,
1297
1364
  populateCache,
1298
- limit: maxResults
1365
+ limit: jsCosineNeedsTierSort ? candidateRows.length : maxResults,
1366
+ skipSort: jsCosineNeedsTierSort
1367
+ // read() re-sorts after applying tier weights
1299
1368
  });
1300
- scoredAlreadySortedAndLimited = true;
1301
1369
  }
1302
1370
  if (scored.length > 0) {
1303
- if (!usedKeywordFallback && !scoredAlreadySortedAndLimited) {
1304
- this._tieBreakSort(scored);
1371
+ scored = scored.map((row) => ({
1372
+ ...row,
1373
+ score: applyTierWeight(row.score, row.entity_id, sanitizedTierWeights)
1374
+ }));
1375
+ this._tieBreakSort(scored);
1376
+ const selectedScored = scored.slice(0, maxResults);
1377
+ const topIds = selectedScored.map((s) => s.id);
1378
+ if (exposeMetadata && trimmedQuery) {
1379
+ scoreByFactId = new Map(selectedScored.map((s) => [s.id, Number.isFinite(s.score) ? s.score : 0]));
1305
1380
  }
1306
- const topIds = (scoredAlreadySortedAndLimited ? scored : scored.slice(0, maxResults)).map((s) => s.id);
1307
1381
  if (topIds.length > 0) {
1308
- const fullRows = [];
1309
- const phase2ChunkSize = 500;
1310
- for (let i = 0; i < topIds.length; i += phase2ChunkSize) {
1311
- const idChunk = topIds.slice(i, i + phase2ChunkSize);
1312
- const placeholders = idChunk.map(() => "?").join(",");
1313
- const chunkRows = await this.db.getAllAsync(
1314
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1315
- [...idChunk, entityId]
1316
- );
1317
- fullRows.push(...chunkRows);
1318
- }
1319
- const byId = new Map(fullRows.map((r) => [r.id, r]));
1320
- facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1321
- if (facts.length < topIds.length) {
1322
- const missingIds = topIds.filter((id) => !byId.has(id));
1382
+ const facts2 = await this._hydrateFactsByIds(topIds, entityIds);
1383
+ if (facts2.length < topIds.length) {
1384
+ const hydrationById = new Set(facts2.map((f) => f.id));
1385
+ const missingIds = topIds.filter((id) => !hydrationById.has(id));
1323
1386
  const missingCount = missingIds.length;
1324
1387
  const sample = missingIds.slice(0, 5);
1325
1388
  const sampleSuffix = sample.length > 0 ? ` Missing ID sample: ${sample.join(", ")}${missingIds.length > sample.length ? ", ..." : ""}.` : "";
1326
1389
  const error = new Error(
1327
- `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
1390
+ `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
1328
1391
  );
1329
1392
  this.options.onRetrievalFallback?.(error);
1330
1393
  }
1394
+ facts = facts2;
1331
1395
  }
1332
1396
  if (pendingRankerFallbackError) {
1333
1397
  this.options.onRetrievalFallback?.(pendingRankerFallbackError);
@@ -1354,26 +1418,28 @@ After running the migration SQL, restart your application.`
1354
1418
  this.options.onRetrievalFallback?.(error);
1355
1419
  }
1356
1420
  }
1357
- if (!usedEmbed) {
1421
+ if (!usedEmbed && scoredEntityIds.length > 0) {
1422
+ const fallbackEntityIdSet = new Set(scoredEntityIds);
1423
+ const fallbackOversampledLimit = Math.max(maxResults * 2, maxResults + 50);
1358
1424
  const results = this.miniSearch.search(trimmedQuery, {
1359
- filter: (r) => r.entity_id === entityId,
1425
+ filter: (r) => fallbackEntityIdSet.has(r.entity_id),
1360
1426
  combineWith: "OR"
1361
1427
  });
1362
- const topIds = results.slice(0, maxResults).map((r) => r.id);
1428
+ const candidates = results.slice(0, fallbackOversampledLimit).map((r) => ({
1429
+ id: r.id,
1430
+ entity_id: r.entity_id,
1431
+ score: applyTierWeight(r.score ?? 0, r.entity_id, sanitizedTierWeights),
1432
+ updated_at: null,
1433
+ access_count: null
1434
+ }));
1435
+ this._tieBreakSort(candidates);
1436
+ const topCandidates = candidates.slice(0, maxResults);
1437
+ const topIds = topCandidates.map((c) => c.id);
1363
1438
  if (topIds.length > 0) {
1364
- const kwRows = [];
1365
- const kwChunkSize = 500;
1366
- for (let i = 0; i < topIds.length; i += kwChunkSize) {
1367
- const idChunk = topIds.slice(i, i + kwChunkSize);
1368
- const placeholders = idChunk.map(() => "?").join(",");
1369
- const chunkRows = await this.db.getAllAsync(
1370
- `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders}) AND entity_id = ? AND deleted_at IS NULL`,
1371
- [...idChunk, entityId]
1372
- );
1373
- kwRows.push(...chunkRows);
1439
+ facts = await this._hydrateFactsByIds(topIds, entityIds);
1440
+ if (exposeMetadata) {
1441
+ scoreByFactId = new Map(topCandidates.map((c) => [c.id, Number.isFinite(c.score) ? c.score : 0]));
1374
1442
  }
1375
- const byId = new Map(kwRows.map((r) => [r.id, r]));
1376
- facts = topIds.map((id) => byId.get(id)).filter((f) => f !== void 0);
1377
1443
  }
1378
1444
  }
1379
1445
  if (facts.length > 0) {
@@ -1392,37 +1458,66 @@ After running the migration SQL, restart your application.`
1392
1458
  }
1393
1459
  }
1394
1460
  } else {
1395
- facts = await this.db.getAllAsync(
1461
+ const entityScope = this._entityInClause(entityIds);
1462
+ const rawFacts = await this.db.getAllAsync(
1396
1463
  `SELECT * FROM ${this.prefix}entries
1397
- WHERE entity_id = ? AND deleted_at IS NULL
1464
+ WHERE ${entityScope.clause} AND deleted_at IS NULL
1398
1465
  ORDER BY updated_at DESC
1399
1466
  LIMIT ?`,
1400
- [entityId, maxResults]
1467
+ [...entityScope.params, maxResults]
1401
1468
  );
1469
+ facts = rawFacts.map((f) => {
1470
+ const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
1471
+ return {
1472
+ ...rest,
1473
+ tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1474
+ };
1475
+ });
1402
1476
  }
1403
1477
  const [tasks, events] = await Promise.all([
1404
- this.db.getAllAsync(
1405
- `SELECT * FROM ${this.prefix}tasks
1406
- WHERE entity_id = ? AND status IN ('pending', 'in_progress') AND deleted_at IS NULL
1407
- ORDER BY priority DESC, created_at ASC`,
1408
- [entityId]
1409
- ),
1410
- this.db.getAllAsync(
1411
- `SELECT * FROM ${this.prefix}events
1412
- WHERE entity_id = ?
1413
- ORDER BY created_at DESC
1414
- LIMIT 10`,
1415
- [entityId]
1416
- )
1478
+ (async () => {
1479
+ const entityScope = this._entityInClause(entityIds);
1480
+ const tasksLimit = entityIds.length === 1 ? void 0 : Math.min(20 * entityIds.length, 200);
1481
+ return this.db.getAllAsync(
1482
+ `SELECT * FROM ${this.prefix}tasks
1483
+ WHERE ${entityScope.clause} AND status IN ('pending', 'in_progress') AND deleted_at IS NULL
1484
+ ORDER BY priority DESC, created_at ASC${tasksLimit !== void 0 ? "\n LIMIT ?" : ""}`,
1485
+ tasksLimit !== void 0 ? [...entityScope.params, tasksLimit] : entityScope.params
1486
+ );
1487
+ })(),
1488
+ (async () => {
1489
+ const entityScope = this._entityInClause(entityIds);
1490
+ const eventsLimit = Math.min(10 * entityIds.length, 100);
1491
+ return this.db.getAllAsync(
1492
+ `SELECT * FROM ${this.prefix}events
1493
+ WHERE ${entityScope.clause}
1494
+ ORDER BY created_at DESC
1495
+ LIMIT ?`,
1496
+ [...entityScope.params, eventsLimit]
1497
+ );
1498
+ })()
1417
1499
  ]);
1418
- const parsedFacts = facts.map((f) => {
1419
- const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
1420
- return {
1421
- ...rest,
1422
- tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1423
- };
1500
+ let factScores;
1501
+ if (exposeMetadata && trimmedQuery && scoreByFactId) {
1502
+ factScores = Object.fromEntries(facts.map((fact) => [fact.id, scoreByFactId.get(fact.id) ?? 0]));
1503
+ }
1504
+ const bundle = { facts, tasks, events: events.reverse() };
1505
+ if (exposeMetadata) {
1506
+ bundle.metadata = { query, entityIds };
1507
+ if (sanitizedTierWeights && Object.keys(sanitizedTierWeights).length > 0) bundle.metadata.tierWeights = sanitizedTierWeights;
1508
+ if (factScores && Object.keys(factScores).length > 0) bundle.factScores = factScores;
1509
+ }
1510
+ return bundle;
1511
+ }
1512
+ /**
1513
+ * Returns entity IDs that will participate in scored retrieval.
1514
+ * Excludes zero-weight entities unless includeZeroWeightEntities is true.
1515
+ */
1516
+ _filterScoredEntities(entityIds, sanitizedTierWeights, includeZeroWeightEntities) {
1517
+ return entityIds.filter((id) => {
1518
+ const w = sanitizedTierWeights?.[id] ?? 1;
1519
+ return includeZeroWeightEntities === true || w !== 0;
1424
1520
  });
1425
- return { facts: parsedFacts, tasks, events: events.reverse() };
1426
1521
  }
1427
1522
  /**
1428
1523
  * Stable tie-break sort: score desc → access_count desc → updated_at desc → id asc.
@@ -1436,13 +1531,48 @@ After running the migration SQL, restart your application.`
1436
1531
  */
1437
1532
  _compareScoredRows(a, b) {
1438
1533
  const scoreDiff = b.score - a.score;
1439
- if (scoreDiff !== 0) return scoreDiff;
1534
+ if (!Number.isNaN(scoreDiff) && scoreDiff !== 0) return scoreDiff;
1440
1535
  const accessCountDiff = (b.access_count ?? 0) - (a.access_count ?? 0);
1441
1536
  if (accessCountDiff !== 0) return accessCountDiff;
1442
1537
  const updatedAtDiff = (b.updated_at ?? 0) - (a.updated_at ?? 0);
1443
1538
  if (updatedAtDiff !== 0) return updatedAtDiff;
1444
1539
  return a.id.localeCompare(b.id);
1445
1540
  }
1541
+ /**
1542
+ * Build SQL IN clause with placeholders for multiple entity IDs.
1543
+ */
1544
+ _entityInClause(entityIds) {
1545
+ if (entityIds.length === 0) return { clause: "1=0", params: [] };
1546
+ const placeholders = entityIds.map(() => "?").join(",");
1547
+ return { clause: `entity_id IN (${placeholders})`, params: [...entityIds] };
1548
+ }
1549
+ /**
1550
+ * Hydrate full facts by ID. Pass scopedEntityIds to restrict to requested namespaces in SQL
1551
+ * (defense-in-depth against a rogue VectorRanker returning cross-entity IDs).
1552
+ */
1553
+ async _hydrateFactsByIds(ids, scopedEntityIds) {
1554
+ const fullRows = [];
1555
+ const chunkSize = 500;
1556
+ const entityClause = scopedEntityIds && scopedEntityIds.length > 0 ? ` AND entity_id IN (${scopedEntityIds.map(() => "?").join(",")})` : "";
1557
+ const entityParams = scopedEntityIds && scopedEntityIds.length > 0 ? [...scopedEntityIds] : [];
1558
+ for (let i = 0; i < ids.length; i += chunkSize) {
1559
+ const idChunk = ids.slice(i, i + chunkSize);
1560
+ const placeholders = idChunk.map(() => "?").join(",");
1561
+ const chunkRows = await this.db.getAllAsync(
1562
+ `SELECT * FROM ${this.prefix}entries WHERE id IN (${placeholders})${entityClause} AND deleted_at IS NULL`,
1563
+ [...idChunk, ...entityParams]
1564
+ );
1565
+ fullRows.push(...chunkRows);
1566
+ }
1567
+ const byId = new Map(fullRows.map((row) => [row.id, row]));
1568
+ return ids.map((id) => byId.get(id)).filter((fact) => fact !== void 0).map((fact) => {
1569
+ const { embedding: _embedding, embedding_blob: _blob, ...rest } = fact;
1570
+ return {
1571
+ ...rest,
1572
+ tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1573
+ };
1574
+ });
1575
+ }
1446
1576
  /**
1447
1577
  * Strip potentially sensitive data from ranker errors before exposing to host callbacks.
1448
1578
  * Preserves error type for debugging but removes message/stack that may contain credentials.
@@ -1467,7 +1597,7 @@ After running the migration SQL, restart your application.`
1467
1597
  */
1468
1598
  async _rankWithJsCosine(args) {
1469
1599
  const queryVec = args.queryVec instanceof Float32Array ? args.queryVec.slice() : Array.from(args.queryVec);
1470
- const { entityId, candidateRows, weight, miniSearchScores, populateCache, limit } = args;
1600
+ const { entityId, candidateRows, weight, miniSearchScores, populateCache, limit, skipSort } = args;
1471
1601
  let entityCache = this.vectorCache.get(entityId);
1472
1602
  const tooLarge = populateCache && candidateRows.length > _WikiMemory.MAX_VECTOR_CACHE_FACTS_PER_ENTITY;
1473
1603
  if (tooLarge && entityCache) {
@@ -1498,7 +1628,13 @@ After running the migration SQL, restart your application.`
1498
1628
  } else {
1499
1629
  score = -2;
1500
1630
  }
1501
- return { id: row.id, score, updated_at: row.updated_at, access_count: row.access_count };
1631
+ return {
1632
+ id: row.id,
1633
+ entity_id: row.entity_id,
1634
+ score,
1635
+ updated_at: row.updated_at,
1636
+ access_count: row.access_count
1637
+ };
1502
1638
  });
1503
1639
  if (canCache && entityCache && entityCache.size > 0) {
1504
1640
  if (!this.vectorCache.has(entityId)) {
@@ -1509,7 +1645,7 @@ After running the migration SQL, restart your application.`
1509
1645
  this.vectorCache.set(entityId, entityCache);
1510
1646
  }
1511
1647
  }
1512
- this._tieBreakSort(scored);
1648
+ if (!skipSort) this._tieBreakSort(scored);
1513
1649
  return scored.slice(0, limit);
1514
1650
  }
1515
1651
  /**
@@ -1518,7 +1654,7 @@ After running the migration SQL, restart your application.`
1518
1654
  * Returns scored results ready for hybrid blending and tie-break sorting.
1519
1655
  */
1520
1656
  async _rankWithVectorRanker(args) {
1521
- const { entityId, candidateIds, weight, miniSearchScores, limit } = args;
1657
+ const { entityId, candidateIds, candidateRows, weight, miniSearchScores, limit } = args;
1522
1658
  const ranker = this.options.vectorRanker;
1523
1659
  if (!ranker) {
1524
1660
  throw new Error("vectorRanker not configured");
@@ -1530,7 +1666,7 @@ After running the migration SQL, restart your application.`
1530
1666
  candidateIds,
1531
1667
  limit
1532
1668
  });
1533
- const allowedIds = candidateIds ? new Set(candidateIds) : void 0;
1669
+ const allowedIds = new Set(candidateRows.map((row) => row.id));
1534
1670
  const seen = /* @__PURE__ */ new Set();
1535
1671
  const normalized = [];
1536
1672
  for (const r of rankerResults) {
@@ -1541,13 +1677,19 @@ After running the migration SQL, restart your application.`
1541
1677
  seen.add(r.id);
1542
1678
  normalized.push(r);
1543
1679
  }
1680
+ const entityIdByCandidateId = new Map(candidateRows.map((row) => [row.id, row.entity_id]));
1544
1681
  const scored = normalized.map((r) => {
1545
1682
  let score = r.semanticScore;
1546
1683
  if (weight !== void 0) {
1547
1684
  const kwScore = miniSearchScores?.get(r.id) ?? 0;
1548
1685
  score = weight * Math.max(0, r.semanticScore) + (1 - weight) * kwScore;
1549
1686
  }
1550
- return { id: r.id, score };
1687
+ return {
1688
+ id: r.id,
1689
+ entity_id: entityIdByCandidateId.get(r.id),
1690
+ // allowedIds filter above guarantees membership
1691
+ score
1692
+ };
1551
1693
  });
1552
1694
  return scored;
1553
1695
  }