@hanhnd/agent-kit 1.0.37 → 1.0.39
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/cli.js +241 -112
- package/dist/mcp/memory.js +1 -1
- package/dist/server.js +244 -115
- package/dist/services/digest/digest-processor.test.js +1 -3
- package/dist/services/memory/constants.d.ts +3 -0
- package/dist/services/memory/constants.js +6 -0
- package/dist/services/memory/indexer.js +37 -54
- package/dist/services/memory/indexer.test.js +229 -231
- package/dist/services/memory/store.d.ts +4 -11
- package/dist/services/memory/store.js +206 -72
- package/dist/services/memory/store.test.js +359 -108
- package/dist/services/memory/types.d.ts +28 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -25930,8 +25930,8 @@ function getElementAtPath(obj, path15) {
|
|
|
25930
25930
|
}
|
|
25931
25931
|
function promiseAllObject(promisesObj) {
|
|
25932
25932
|
const keys = Object.keys(promisesObj);
|
|
25933
|
-
const
|
|
25934
|
-
return Promise.all(
|
|
25933
|
+
const promises3 = keys.map((key) => promisesObj[key]);
|
|
25934
|
+
return Promise.all(promises3).then((results) => {
|
|
25935
25935
|
const resolvedObj = {};
|
|
25936
25936
|
for (let i = 0; i < keys.length; i++) {
|
|
25937
25937
|
resolvedObj[keys[i]] = results[i];
|
|
@@ -35920,6 +35920,9 @@ var DEFAULT_MEMORY_CONFIG = {
|
|
|
35920
35920
|
var LOCK_RETRY_MS = 50;
|
|
35921
35921
|
var LOCK_TIMEOUT_MS = 500;
|
|
35922
35922
|
var RRF_K = 60;
|
|
35923
|
+
var FETCH_MULTIPLIER = 4;
|
|
35924
|
+
var RECENCY_WEIGHT = 0.5;
|
|
35925
|
+
var DENSE_SCORE_FLOOR = 0.2;
|
|
35923
35926
|
|
|
35924
35927
|
// src/utils/files.ts
|
|
35925
35928
|
import * as fs from "node:fs";
|
|
@@ -37013,14 +37016,14 @@ var MemoryIndexer = class {
|
|
|
37013
37016
|
const existingChunkIds = this.store.hashesBySource(source);
|
|
37014
37017
|
let text;
|
|
37015
37018
|
try {
|
|
37016
|
-
text = fs6.
|
|
37019
|
+
text = await fs6.promises.readFile(absolutePath, "utf8");
|
|
37017
37020
|
} catch (err) {
|
|
37018
37021
|
console.warn(`[memory-indexer] Cannot read file ${absolutePath}:`, err);
|
|
37019
37022
|
return { indexed: 0, deleted: 0, skipped: existingChunkIds.size };
|
|
37020
37023
|
}
|
|
37021
37024
|
let fileMtimeAt;
|
|
37022
37025
|
try {
|
|
37023
|
-
fileMtimeAt = fs6.
|
|
37026
|
+
fileMtimeAt = (await fs6.promises.stat(absolutePath)).mtimeMs;
|
|
37024
37027
|
} catch (err) {
|
|
37025
37028
|
console.warn(`[memory-indexer] Cannot stat file ${absolutePath}:`, err);
|
|
37026
37029
|
fileMtimeAt = Date.now();
|
|
@@ -37053,22 +37056,30 @@ var MemoryIndexer = class {
|
|
|
37053
37056
|
}
|
|
37054
37057
|
async indexDirectory(rootDir, opts = {}) {
|
|
37055
37058
|
const totals = { indexed: 0, deleted: 0, skipped: 0 };
|
|
37056
|
-
|
|
37059
|
+
let rootExists;
|
|
37060
|
+
try {
|
|
37061
|
+
await fs6.promises.stat(rootDir);
|
|
37062
|
+
rootExists = true;
|
|
37063
|
+
} catch {
|
|
37064
|
+
rootExists = false;
|
|
37065
|
+
}
|
|
37066
|
+
if (!rootExists) return totals;
|
|
37057
37067
|
const relativeBase = opts.relativeBase ?? this.config.wikiDir;
|
|
37058
37068
|
const excludeFiles = new Set(opts.excludeFiles ?? []);
|
|
37059
37069
|
const files = [];
|
|
37060
|
-
const walk = (dirPath) => {
|
|
37070
|
+
const walk = async (dirPath) => {
|
|
37061
37071
|
let entries;
|
|
37062
37072
|
try {
|
|
37063
|
-
entries = fs6.
|
|
37073
|
+
entries = await fs6.promises.readdir(dirPath, { withFileTypes: true });
|
|
37064
37074
|
} catch (err) {
|
|
37065
37075
|
console.warn(`[memory-indexer] Cannot scan directory ${dirPath}:`, err);
|
|
37066
37076
|
return;
|
|
37067
37077
|
}
|
|
37078
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
37068
37079
|
for (const entry of entries) {
|
|
37069
37080
|
const entryPath = path8.join(dirPath, entry.name);
|
|
37070
37081
|
if (entry.isDirectory()) {
|
|
37071
|
-
walk(entryPath);
|
|
37082
|
+
await walk(entryPath);
|
|
37072
37083
|
continue;
|
|
37073
37084
|
}
|
|
37074
37085
|
if (entry.isFile() && /\.md$/i.test(entry.name) && !excludeFiles.has(entry.name)) {
|
|
@@ -37076,7 +37087,7 @@ var MemoryIndexer = class {
|
|
|
37076
37087
|
}
|
|
37077
37088
|
}
|
|
37078
37089
|
};
|
|
37079
|
-
walk(rootDir);
|
|
37090
|
+
await walk(rootDir);
|
|
37080
37091
|
const currentSources = new Set(files.map((file) => path8.relative(relativeBase, file)));
|
|
37081
37092
|
for (const stale of this.store.indexedSources()) {
|
|
37082
37093
|
if (!currentSources.has(stale)) {
|
|
@@ -37093,58 +37104,35 @@ var MemoryIndexer = class {
|
|
|
37093
37104
|
return totals;
|
|
37094
37105
|
}
|
|
37095
37106
|
async search(query, topK) {
|
|
37096
|
-
const fetchLimit = topK *
|
|
37097
|
-
let
|
|
37107
|
+
const fetchLimit = topK * FETCH_MULTIPLIER;
|
|
37108
|
+
let embedding;
|
|
37098
37109
|
if (this.store.vecAvailable) {
|
|
37099
37110
|
try {
|
|
37100
|
-
const
|
|
37101
|
-
|
|
37111
|
+
const embeddings = await this.embedder.embed([query]);
|
|
37112
|
+
embedding = embeddings[0];
|
|
37102
37113
|
} catch (err) {
|
|
37103
37114
|
console.warn("[memory-indexer] Dense search embedding failed:", err);
|
|
37104
37115
|
}
|
|
37105
37116
|
}
|
|
37106
|
-
const
|
|
37107
|
-
|
|
37108
|
-
|
|
37109
|
-
|
|
37110
|
-
|
|
37111
|
-
|
|
37112
|
-
|
|
37113
|
-
|
|
37114
|
-
});
|
|
37115
|
-
bm25Results.forEach((r, rank) => {
|
|
37116
|
-
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
|
|
37117
|
-
entry.bm25 = 1 / (RRF_K + rank + 1);
|
|
37118
|
-
scoreMap.set(r.id, entry);
|
|
37117
|
+
const rows = this.store.searchHybrid({
|
|
37118
|
+
query,
|
|
37119
|
+
embedding,
|
|
37120
|
+
topK,
|
|
37121
|
+
fetchLimit,
|
|
37122
|
+
denseScoreFloor: DENSE_SCORE_FLOOR,
|
|
37123
|
+
recencyWeight: RECENCY_WEIGHT,
|
|
37124
|
+
rrfK: RRF_K
|
|
37119
37125
|
});
|
|
37120
|
-
|
|
37121
|
-
const numRetrievers = hasDense ? 2 : 1;
|
|
37122
|
-
const maxScore = numRetrievers / (RRF_K + 1);
|
|
37123
|
-
const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
|
|
37124
|
-
id,
|
|
37125
|
-
totalScore: scores.dense + scores.bm25,
|
|
37126
|
-
hasDense: scores.dense > 0,
|
|
37127
|
-
hasBm25: scores.bm25 > 0
|
|
37128
|
-
})).sort((a, b) => b.totalScore - a.totalScore);
|
|
37129
|
-
const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
|
|
37130
|
-
const chunkMap = new Map(chunks.map((c) => [c.id, c]));
|
|
37126
|
+
if (rows.length === 0) return [];
|
|
37131
37127
|
const results = [];
|
|
37132
|
-
const
|
|
37133
|
-
for (const r of ranked) {
|
|
37134
|
-
if (results.length >= topK) break;
|
|
37135
|
-
const chunk = chunkMap.get(r.id);
|
|
37136
|
-
if (!chunk) continue;
|
|
37137
|
-
if (seenSources.has(chunk.source)) continue;
|
|
37138
|
-
seenSources.add(chunk.source);
|
|
37139
|
-
const normalizedScore = maxScore > 0 ? r.totalScore / maxScore : 0;
|
|
37140
|
-
const retriever = r.hasDense && r.hasBm25 ? "both" : r.hasDense ? "dense" : "bm25";
|
|
37128
|
+
for (const row of rows) {
|
|
37141
37129
|
let contentSource = "file";
|
|
37142
37130
|
try {
|
|
37143
|
-
chunk.content = fs6.
|
|
37131
|
+
row.chunk.content = await fs6.promises.readFile(path8.join(this.config.wikiDir, row.chunk.source), "utf8");
|
|
37144
37132
|
} catch {
|
|
37145
37133
|
contentSource = "fallback";
|
|
37146
37134
|
}
|
|
37147
|
-
results.push({ chunk, score:
|
|
37135
|
+
results.push({ chunk: row.chunk, score: row.score, retriever: row.retriever, contentSource });
|
|
37148
37136
|
}
|
|
37149
37137
|
return results;
|
|
37150
37138
|
}
|
|
@@ -37153,13 +37141,13 @@ var MemoryIndexer = class {
|
|
|
37153
37141
|
const rawDir = path8.join(this.config.wikiDir, "raw");
|
|
37154
37142
|
const savePath = path8.join(rawDir, `conv_save_${datePart}.md`);
|
|
37155
37143
|
const lockPath = `${savePath}.lock`;
|
|
37156
|
-
fs6.
|
|
37144
|
+
await fs6.promises.mkdir(rawDir, { recursive: true });
|
|
37157
37145
|
const acquired = await acquireLock(lockPath, LOCK_TIMEOUT_MS, LOCK_RETRY_MS);
|
|
37158
37146
|
if (!acquired) {
|
|
37159
37147
|
console.warn("[memory-indexer] Could not acquire write lock, writing anyway (fail-open)");
|
|
37160
37148
|
}
|
|
37161
37149
|
try {
|
|
37162
|
-
fs6.
|
|
37150
|
+
await fs6.promises.appendFile(savePath, `
|
|
37163
37151
|
${content}
|
|
37164
37152
|
`, "utf8");
|
|
37165
37153
|
} finally {
|
|
@@ -37321,6 +37309,7 @@ var MemoryStore = class {
|
|
|
37321
37309
|
return 0;
|
|
37322
37310
|
}
|
|
37323
37311
|
upsert(chunks, embeddings) {
|
|
37312
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
37324
37313
|
const insertChunk = this.db.prepare(`
|
|
37325
37314
|
INSERT OR REPLACE INTO memory_chunks
|
|
37326
37315
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -37331,7 +37320,7 @@ var MemoryStore = class {
|
|
|
37331
37320
|
const chunk = chunks[i];
|
|
37332
37321
|
try {
|
|
37333
37322
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
37334
|
-
|
|
37323
|
+
deleteChunk.run(chunk.id);
|
|
37335
37324
|
insertChunk.run(
|
|
37336
37325
|
chunk.id,
|
|
37337
37326
|
chunk.source,
|
|
@@ -37360,15 +37349,10 @@ var MemoryStore = class {
|
|
|
37360
37349
|
if (!embedding) throw new Error("missing embedding");
|
|
37361
37350
|
if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
|
|
37362
37351
|
if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
|
|
37363
|
-
|
|
37364
|
-
if (!Number.isFinite(
|
|
37365
|
-
|
|
37366
|
-
|
|
37367
|
-
return JSON.stringify(values);
|
|
37368
|
-
}
|
|
37369
|
-
normalizeVectorDistance(distance) {
|
|
37370
|
-
if (distance <= 0) return 1;
|
|
37371
|
-
return Math.max(0, Math.min(1, 1 - distance));
|
|
37352
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
37353
|
+
if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
|
|
37354
|
+
}
|
|
37355
|
+
return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
37372
37356
|
}
|
|
37373
37357
|
hashesBySource(source) {
|
|
37374
37358
|
const rows = this.db.prepare(`SELECT id FROM memory_chunks WHERE source = ?`).all(source);
|
|
@@ -37406,61 +37390,12 @@ var MemoryStore = class {
|
|
|
37406
37390
|
});
|
|
37407
37391
|
deleteAll();
|
|
37408
37392
|
}
|
|
37409
|
-
|
|
37410
|
-
try {
|
|
37411
|
-
const queryVector = this.toVectorBinding(embedding);
|
|
37412
|
-
const rows = this.db.prepare(
|
|
37413
|
-
`SELECT mc.id, vector_distance_cos(mc.embedding, vector32(?)) AS distance
|
|
37414
|
-
FROM vector_top_k('idx_memory_chunks_embedding', vector32(?), ?) AS vector_matches
|
|
37415
|
-
JOIN memory_chunks mc ON mc.rowid = vector_matches.id
|
|
37416
|
-
ORDER BY distance
|
|
37417
|
-
LIMIT ?`
|
|
37418
|
-
).all(queryVector, queryVector, limit, limit);
|
|
37419
|
-
return rows.map((r) => ({
|
|
37420
|
-
id: r.id,
|
|
37421
|
-
score: this.normalizeVectorDistance(r.distance)
|
|
37422
|
-
}));
|
|
37423
|
-
} catch (err) {
|
|
37424
|
-
console.warn("[memory-store] Dense search failed:", err);
|
|
37425
|
-
return [];
|
|
37426
|
-
}
|
|
37427
|
-
}
|
|
37428
|
-
searchBm25(query, limit) {
|
|
37429
|
-
if (!query.trim()) return [];
|
|
37393
|
+
buildFtsQuery(query) {
|
|
37430
37394
|
const tokens = query.trim().split(/\s+/).map((t) => t.replace(/[^a-zA-Z0-9_]/g, "").toLowerCase()).filter((t) => t.length > 1);
|
|
37431
|
-
|
|
37432
|
-
if (!ftsQuery) return [];
|
|
37433
|
-
try {
|
|
37434
|
-
const rows = this.db.prepare(
|
|
37435
|
-
`SELECT mc.id, bm25(memory_fts, 0.2, 2.0, 5.0) AS rank
|
|
37436
|
-
FROM memory_fts
|
|
37437
|
-
JOIN memory_chunks mc ON mc.rowid = memory_fts.rowid
|
|
37438
|
-
WHERE memory_fts MATCH ?
|
|
37439
|
-
ORDER BY rank
|
|
37440
|
-
LIMIT ?`
|
|
37441
|
-
).all(ftsQuery, limit);
|
|
37442
|
-
if (rows.length === 0) return [];
|
|
37443
|
-
const ranks = rows.map((r) => r.rank);
|
|
37444
|
-
const minRank = Math.min(...ranks);
|
|
37445
|
-
const maxRank = Math.max(...ranks);
|
|
37446
|
-
const range = maxRank - minRank;
|
|
37447
|
-
return rows.map((r) => ({
|
|
37448
|
-
id: r.id,
|
|
37449
|
-
score: range > 0 ? (maxRank - r.rank) / range : 1
|
|
37450
|
-
}));
|
|
37451
|
-
} catch (err) {
|
|
37452
|
-
console.warn("[memory-store] BM25 search failed:", err);
|
|
37453
|
-
return [];
|
|
37454
|
-
}
|
|
37395
|
+
return (0, import_stopword.removeStopwords)(tokens, import_stopword.eng).map((t) => `${t.replace(/s$/, "")}*`).join(" OR ");
|
|
37455
37396
|
}
|
|
37456
|
-
|
|
37457
|
-
|
|
37458
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
37459
|
-
const rows = this.db.prepare(
|
|
37460
|
-
`SELECT id, source, source_type, heading, heading_level, content, line_start, line_end, file_mtime_at
|
|
37461
|
-
FROM memory_chunks WHERE id IN (${placeholders})`
|
|
37462
|
-
).all(...ids);
|
|
37463
|
-
return rows.map((r) => ({
|
|
37397
|
+
mapMemoryChunkRow(r) {
|
|
37398
|
+
return {
|
|
37464
37399
|
id: r.id,
|
|
37465
37400
|
source: r.source,
|
|
37466
37401
|
sourceType: r.source_type,
|
|
@@ -37470,7 +37405,201 @@ var MemoryStore = class {
|
|
|
37470
37405
|
lineStart: r.line_start,
|
|
37471
37406
|
lineEnd: r.line_end,
|
|
37472
37407
|
fileMtimeAt: r.file_mtime_at
|
|
37473
|
-
}
|
|
37408
|
+
};
|
|
37409
|
+
}
|
|
37410
|
+
searchHybrid(options) {
|
|
37411
|
+
const {
|
|
37412
|
+
query,
|
|
37413
|
+
embedding,
|
|
37414
|
+
topK,
|
|
37415
|
+
fetchLimit,
|
|
37416
|
+
denseScoreFloor,
|
|
37417
|
+
recencyWeight,
|
|
37418
|
+
rrfK,
|
|
37419
|
+
sourceType,
|
|
37420
|
+
sinceMtimeAt,
|
|
37421
|
+
includeDebug
|
|
37422
|
+
} = options;
|
|
37423
|
+
const ftsQuery = this.buildFtsQuery(query);
|
|
37424
|
+
const hasDense = embedding !== void 0;
|
|
37425
|
+
const hasBm25 = ftsQuery.length > 0;
|
|
37426
|
+
if (!hasDense && !hasBm25) return [];
|
|
37427
|
+
const hasFilters = sourceType !== void 0 || sinceMtimeAt !== void 0;
|
|
37428
|
+
try {
|
|
37429
|
+
const params = [];
|
|
37430
|
+
let denseCte = "";
|
|
37431
|
+
let denseRankedCte = "";
|
|
37432
|
+
if (hasDense) {
|
|
37433
|
+
const queryBuf = this.toVectorBinding(embedding);
|
|
37434
|
+
if (hasFilters) {
|
|
37435
|
+
const filterClauses = [];
|
|
37436
|
+
if (sourceType) {
|
|
37437
|
+
filterClauses.push("mc.source_type = ?");
|
|
37438
|
+
params.push(sourceType);
|
|
37439
|
+
}
|
|
37440
|
+
if (sinceMtimeAt !== void 0) {
|
|
37441
|
+
filterClauses.push("mc.file_mtime_at >= ?");
|
|
37442
|
+
params.push(sinceMtimeAt);
|
|
37443
|
+
}
|
|
37444
|
+
const whereClause = filterClauses.length > 0 ? `WHERE ${filterClauses.join(" AND ")}` : "";
|
|
37445
|
+
params.push(queryBuf);
|
|
37446
|
+
params.push(fetchLimit);
|
|
37447
|
+
denseCte = `dense_data AS (
|
|
37448
|
+
SELECT mc.id, mc.source, mc.source_type, mc.file_mtime_at,
|
|
37449
|
+
mc.heading, mc.heading_level, mc.content, mc.line_start, mc.line_end,
|
|
37450
|
+
vector_distance_cos(mc.embedding, vector32(?)) AS dense_dist
|
|
37451
|
+
FROM memory_chunks mc
|
|
37452
|
+
${whereClause}
|
|
37453
|
+
ORDER BY dense_dist
|
|
37454
|
+
LIMIT ?
|
|
37455
|
+
)`;
|
|
37456
|
+
} else {
|
|
37457
|
+
params.push(queryBuf);
|
|
37458
|
+
params.push(queryBuf);
|
|
37459
|
+
params.push(fetchLimit);
|
|
37460
|
+
denseCte = `dense_data AS (
|
|
37461
|
+
SELECT mc.id, mc.source, mc.source_type, mc.file_mtime_at,
|
|
37462
|
+
mc.heading, mc.heading_level, mc.content, mc.line_start, mc.line_end,
|
|
37463
|
+
vector_distance_cos(mc.embedding, vector32(?)) AS dense_dist
|
|
37464
|
+
FROM vector_top_k('idx_memory_chunks_embedding', vector32(?), ?) AS vtk
|
|
37465
|
+
JOIN memory_chunks mc ON mc.rowid = vtk.id
|
|
37466
|
+
)`;
|
|
37467
|
+
}
|
|
37468
|
+
denseRankedCte = `dense_ranked AS (
|
|
37469
|
+
SELECT *, ROW_NUMBER() OVER (ORDER BY dense_dist, id) AS dense_rank
|
|
37470
|
+
FROM dense_data
|
|
37471
|
+
)`;
|
|
37472
|
+
}
|
|
37473
|
+
let bm25Cte = "";
|
|
37474
|
+
let bm25RankedCte = "";
|
|
37475
|
+
if (hasBm25) {
|
|
37476
|
+
const bm25FilterClauses = [];
|
|
37477
|
+
if (sourceType) bm25FilterClauses.push("mc.source_type = ?");
|
|
37478
|
+
if (sinceMtimeAt !== void 0) bm25FilterClauses.push("mc.file_mtime_at >= ?");
|
|
37479
|
+
const bm25WhereExtra = bm25FilterClauses.length > 0 ? ` AND ${bm25FilterClauses.join(" AND ")}` : "";
|
|
37480
|
+
params.push(ftsQuery);
|
|
37481
|
+
if (sourceType) params.push(sourceType);
|
|
37482
|
+
if (sinceMtimeAt !== void 0) params.push(sinceMtimeAt);
|
|
37483
|
+
params.push(fetchLimit);
|
|
37484
|
+
bm25Cte = `bm25_data AS (
|
|
37485
|
+
SELECT mc.id, mc.source, mc.source_type, mc.file_mtime_at,
|
|
37486
|
+
mc.heading, mc.heading_level, mc.content, mc.line_start, mc.line_end,
|
|
37487
|
+
bm25(memory_fts, 0.2, 2.0, 5.0) AS bm25_neg
|
|
37488
|
+
FROM memory_fts
|
|
37489
|
+
JOIN memory_chunks mc ON mc.rowid = memory_fts.rowid
|
|
37490
|
+
WHERE memory_fts MATCH ?${bm25WhereExtra}
|
|
37491
|
+
LIMIT ?
|
|
37492
|
+
)`;
|
|
37493
|
+
bm25RankedCte = `bm25_ranked AS (
|
|
37494
|
+
SELECT *, ROW_NUMBER() OVER (ORDER BY bm25_neg, id) AS bm25_rank
|
|
37495
|
+
FROM bm25_data
|
|
37496
|
+
)`;
|
|
37497
|
+
}
|
|
37498
|
+
let candidatesCte = "";
|
|
37499
|
+
if (hasDense && hasBm25) {
|
|
37500
|
+
candidatesCte = `all_candidates AS (
|
|
37501
|
+
SELECT d.id, d.source, d.source_type, d.file_mtime_at,
|
|
37502
|
+
d.heading, d.heading_level, d.content, d.line_start, d.line_end,
|
|
37503
|
+
CAST(d.dense_rank AS INTEGER) AS dense_rank, d.dense_dist,
|
|
37504
|
+
b.bm25_rank,
|
|
37505
|
+
CASE WHEN b.id IS NOT NULL THEN 'both' ELSE 'dense' END AS retriever
|
|
37506
|
+
FROM dense_ranked d
|
|
37507
|
+
LEFT JOIN bm25_ranked b ON b.id = d.id
|
|
37508
|
+
UNION ALL
|
|
37509
|
+
SELECT b.id, b.source, b.source_type, b.file_mtime_at,
|
|
37510
|
+
b.heading, b.heading_level, b.content, b.line_start, b.line_end,
|
|
37511
|
+
NULL AS dense_rank, NULL AS dense_dist,
|
|
37512
|
+
CAST(b.bm25_rank AS INTEGER) AS bm25_rank,
|
|
37513
|
+
'bm25' AS retriever
|
|
37514
|
+
FROM bm25_ranked b
|
|
37515
|
+
LEFT JOIN dense_ranked d ON d.id = b.id
|
|
37516
|
+
WHERE d.id IS NULL
|
|
37517
|
+
)`;
|
|
37518
|
+
} else if (hasDense) {
|
|
37519
|
+
candidatesCte = `all_candidates AS (
|
|
37520
|
+
SELECT id, source, source_type, file_mtime_at,
|
|
37521
|
+
heading, heading_level, content, line_start, line_end,
|
|
37522
|
+
CAST(dense_rank AS INTEGER) AS dense_rank, dense_dist,
|
|
37523
|
+
NULL AS bm25_rank, 'dense' AS retriever
|
|
37524
|
+
FROM dense_ranked
|
|
37525
|
+
)`;
|
|
37526
|
+
} else {
|
|
37527
|
+
candidatesCte = `all_candidates AS (
|
|
37528
|
+
SELECT id, source, source_type, file_mtime_at,
|
|
37529
|
+
heading, heading_level, content, line_start, line_end,
|
|
37530
|
+
NULL AS dense_rank, NULL AS dense_dist,
|
|
37531
|
+
CAST(bm25_rank AS INTEGER) AS bm25_rank, 'bm25' AS retriever
|
|
37532
|
+
FROM bm25_ranked
|
|
37533
|
+
)`;
|
|
37534
|
+
}
|
|
37535
|
+
params.push(rrfK, rrfK, recencyWeight, rrfK, denseScoreFloor, topK);
|
|
37536
|
+
const sql = `
|
|
37537
|
+
WITH
|
|
37538
|
+
${[denseCte, denseRankedCte, bm25Cte, bm25RankedCte, candidatesCte].filter(Boolean).join(",\n")}
|
|
37539
|
+
,
|
|
37540
|
+
recency_ranked AS (
|
|
37541
|
+
SELECT id, ROW_NUMBER() OVER (ORDER BY file_mtime_at DESC, source, id) AS recency_rank
|
|
37542
|
+
FROM all_candidates
|
|
37543
|
+
),
|
|
37544
|
+
scored AS (
|
|
37545
|
+
SELECT c.id, c.source, c.source_type, c.file_mtime_at,
|
|
37546
|
+
c.heading, c.heading_level, c.content, c.line_start, c.line_end,
|
|
37547
|
+
c.dense_rank, c.dense_dist, c.bm25_rank, r.recency_rank,
|
|
37548
|
+
c.retriever,
|
|
37549
|
+
COALESCE(1.0 / (? + c.dense_rank), 0.0) +
|
|
37550
|
+
COALESCE(1.0 / (? + c.bm25_rank), 0.0) +
|
|
37551
|
+
? * (1.0 / (? + r.recency_rank)) AS total_score
|
|
37552
|
+
FROM all_candidates c
|
|
37553
|
+
JOIN recency_ranked r ON r.id = c.id
|
|
37554
|
+
WHERE c.retriever != 'dense' OR (1.0 - c.dense_dist) >= ?
|
|
37555
|
+
),
|
|
37556
|
+
best_per_source AS (
|
|
37557
|
+
SELECT *,
|
|
37558
|
+
ROW_NUMBER() OVER (PARTITION BY source ORDER BY total_score DESC, source, id) AS source_rank
|
|
37559
|
+
FROM scored
|
|
37560
|
+
)
|
|
37561
|
+
SELECT id, source, source_type, file_mtime_at, heading, heading_level, content, line_start, line_end,
|
|
37562
|
+
dense_rank, dense_dist, bm25_rank, recency_rank, total_score, retriever
|
|
37563
|
+
FROM best_per_source
|
|
37564
|
+
WHERE source_rank = 1
|
|
37565
|
+
ORDER BY total_score DESC, source, id
|
|
37566
|
+
LIMIT ?
|
|
37567
|
+
`;
|
|
37568
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
37569
|
+
if (rows.length === 0) return [];
|
|
37570
|
+
const denseActive = hasDense;
|
|
37571
|
+
const bm25Active = hasBm25;
|
|
37572
|
+
const recencyActive = rows.length > 0;
|
|
37573
|
+
const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? recencyWeight : 0)) / (rrfK + 1);
|
|
37574
|
+
return rows.map((r) => {
|
|
37575
|
+
const chunk = this.mapMemoryChunkRow(r);
|
|
37576
|
+
const normalizedScore = maxScore > 0 ? r.total_score / maxScore : 0;
|
|
37577
|
+
const row = {
|
|
37578
|
+
chunk,
|
|
37579
|
+
score: normalizedScore,
|
|
37580
|
+
retriever: r.retriever
|
|
37581
|
+
};
|
|
37582
|
+
if (includeDebug) {
|
|
37583
|
+
const denseScore = r.dense_dist !== null ? Math.max(0, Math.min(1, 1 - r.dense_dist)) : void 0;
|
|
37584
|
+
const bm25Score = r.bm25_rank !== null ? 1 / (rrfK + r.bm25_rank) : void 0;
|
|
37585
|
+
const recencyScore = recencyWeight * (1 / (rrfK + r.recency_rank));
|
|
37586
|
+
const debug = {
|
|
37587
|
+
denseRank: r.dense_rank ?? void 0,
|
|
37588
|
+
denseScore,
|
|
37589
|
+
bm25Rank: r.bm25_rank ?? void 0,
|
|
37590
|
+
bm25Score,
|
|
37591
|
+
recencyRank: r.recency_rank,
|
|
37592
|
+
recencyScore,
|
|
37593
|
+
totalScore: r.total_score
|
|
37594
|
+
};
|
|
37595
|
+
row.debug = debug;
|
|
37596
|
+
}
|
|
37597
|
+
return row;
|
|
37598
|
+
});
|
|
37599
|
+
} catch (err) {
|
|
37600
|
+
console.warn("[memory-store] Hybrid search failed:", err);
|
|
37601
|
+
return [];
|
|
37602
|
+
}
|
|
37474
37603
|
}
|
|
37475
37604
|
get vecAvailable() {
|
|
37476
37605
|
return true;
|
|
@@ -37955,7 +38084,7 @@ ${content}`;
|
|
|
37955
38084
|
const blocks = [];
|
|
37956
38085
|
for (const row of rows) {
|
|
37957
38086
|
try {
|
|
37958
|
-
const content = fs11.
|
|
38087
|
+
const content = await fs11.promises.readFile(path14.join(config2.wikiDir, row.source), "utf8");
|
|
37959
38088
|
const displaySource = row.source.replace(/^compiled\//, "");
|
|
37960
38089
|
blocks.push(`### ${displaySource}
|
|
37961
38090
|
${content}`);
|
|
@@ -72,10 +72,8 @@ describe('digestConversationFile', () => {
|
|
|
72
72
|
});
|
|
73
73
|
assert.equal(result.indexed, true);
|
|
74
74
|
assert.equal(result.markdown, markdownPath);
|
|
75
|
-
const denseResults = store.searchDense(new Float32Array(384).fill(0.05), 5);
|
|
76
75
|
const indexedSource = path.relative(config.wikiDir, markdownPath);
|
|
77
|
-
|
|
78
|
-
assert.ok(indexedChunks.some((chunk) => chunk.source === indexedSource), `Expected dense search to return a chunk for ${indexedSource}`);
|
|
76
|
+
assert.ok(store.hashesBySource(indexedSource).size > 0, `Expected source ${indexedSource} to be indexed in store`);
|
|
79
77
|
}
|
|
80
78
|
finally {
|
|
81
79
|
store.close();
|
|
@@ -4,3 +4,6 @@ export declare const DEFAULT_MEMORY_CONFIG: MemoryConfig;
|
|
|
4
4
|
export declare const LOCK_RETRY_MS = 50;
|
|
5
5
|
export declare const LOCK_TIMEOUT_MS = 500;
|
|
6
6
|
export declare const RRF_K = 60;
|
|
7
|
+
export declare const FETCH_MULTIPLIER = 4;
|
|
8
|
+
export declare const RECENCY_WEIGHT = 0.5;
|
|
9
|
+
export declare const DENSE_SCORE_FLOOR = 0.2;
|
|
@@ -16,3 +16,9 @@ export const DEFAULT_MEMORY_CONFIG = {
|
|
|
16
16
|
export const LOCK_RETRY_MS = 50;
|
|
17
17
|
export const LOCK_TIMEOUT_MS = 500;
|
|
18
18
|
export const RRF_K = 60;
|
|
19
|
+
// Tunable starting default: fetchLimit = topK * FETCH_MULTIPLIER (was hardcoded topK * 2)
|
|
20
|
+
export const FETCH_MULTIPLIER = 4;
|
|
21
|
+
// Tunable starting default: weight on the recency RRF channel; < 1 so recency cannot outrank a dual-channel durable hit
|
|
22
|
+
export const RECENCY_WEIGHT = 0.5;
|
|
23
|
+
// Tunable starting default: min dense retrieval score [0,1] for a dense-only candidate to survive (permissive start)
|
|
24
|
+
export const DENSE_SCORE_FLOOR = 0.2;
|