@hanhnd/agent-kit 1.0.36 → 1.0.38

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 CHANGED
@@ -15019,6 +15019,11 @@ import * as os from "os";
15019
15019
  import * as path3 from "path";
15020
15020
 
15021
15021
  // src/services/memory/constants.ts
15022
+ var EMBEDDING_MODEL_DIMENSIONS = {
15023
+ ["tiny" /* TINY */]: 384,
15024
+ ["base" /* BASE */]: 384,
15025
+ ["large" /* LARGE */]: 768
15026
+ };
15022
15027
  var DEFAULT_MEMORY_CONFIG = {
15023
15028
  enabled: false,
15024
15029
  wikiDir: "",
@@ -15026,22 +15031,32 @@ var DEFAULT_MEMORY_CONFIG = {
15026
15031
  chunkSize: 1500,
15027
15032
  overlapLines: 2,
15028
15033
  embeddingModel: "base" /* BASE */,
15029
- vectorDimension: 384
15034
+ vectorDimension: EMBEDDING_MODEL_DIMENSIONS["base" /* BASE */]
15030
15035
  };
15031
15036
  var LOCK_RETRY_MS = 50;
15032
15037
  var LOCK_TIMEOUT_MS = 500;
15033
15038
  var RRF_K = 60;
15039
+ var FETCH_MULTIPLIER = 4;
15040
+ var RECENCY_WEIGHT = 0.5;
15041
+ var DENSE_SCORE_FLOOR = 0.2;
15034
15042
 
15035
15043
  // src/core/config/index.ts
15036
15044
  function resolveMemoryConfig(settings, workspaceRoot) {
15037
15045
  const override = settings.memory ?? {};
15046
+ const embeddingModel = isEmbeddingModelName(override.embeddingModel) ? override.embeddingModel : DEFAULT_MEMORY_CONFIG.embeddingModel;
15047
+ const vectorDimension = typeof override.vectorDimension === "number" && Number.isFinite(override.vectorDimension) && override.vectorDimension > 0 ? override.vectorDimension : EMBEDDING_MODEL_DIMENSIONS[embeddingModel];
15038
15048
  return {
15039
15049
  ...DEFAULT_MEMORY_CONFIG,
15040
15050
  ...override,
15041
15051
  enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
15052
+ embeddingModel,
15053
+ vectorDimension,
15042
15054
  wikiDir: typeof override.wikiDir === "string" && override.wikiDir.length > 0 ? override.wikiDir : path3.join(workspaceRoot, ".agent-kit", "wiki")
15043
15055
  };
15044
15056
  }
15057
+ function isEmbeddingModelName(value) {
15058
+ return value === "tiny" /* TINY */ || value === "base" /* BASE */ || value === "large" /* LARGE */;
15059
+ }
15045
15060
  function getGlobalSettingsFile() {
15046
15061
  return path3.join(os.homedir(), ".agent-kit", "settings.json");
15047
15062
  }
@@ -15313,7 +15328,7 @@ var import_stopword = __toESM(require_stopword_cjs(), 1);
15313
15328
  import Database from "libsql";
15314
15329
  import * as fs5 from "fs";
15315
15330
  import * as path7 from "path";
15316
- var SCHEMA_VERSION = 3;
15331
+ var SCHEMA_VERSION = 4;
15317
15332
  var LibsqlDatabase = Database;
15318
15333
  var StoreError = class extends Error {
15319
15334
  constructor(message, cause) {
@@ -15356,7 +15371,8 @@ var MemoryStore = class {
15356
15371
  createSchema(db) {
15357
15372
  try {
15358
15373
  const current = this.readUserVersion(db);
15359
- if (current < SCHEMA_VERSION) {
15374
+ const currentVectorDimension = current >= SCHEMA_VERSION ? this.readEmbeddingColumnDimension(db) : void 0;
15375
+ if (current < SCHEMA_VERSION || currentVectorDimension !== void 0 && currentVectorDimension !== this.config.vectorDimension) {
15360
15376
  db.exec(`
15361
15377
  DROP TRIGGER IF EXISTS memory_chunks_ai;
15362
15378
  DROP TRIGGER IF EXISTS memory_chunks_ad;
@@ -15413,6 +15429,22 @@ var MemoryStore = class {
15413
15429
  throw new StoreError("schema migration failed", err);
15414
15430
  }
15415
15431
  }
15432
+ readEmbeddingColumnDimension(db) {
15433
+ try {
15434
+ const rows = db.pragma("table_info(memory_chunks)");
15435
+ if (!Array.isArray(rows)) return void 0;
15436
+ const embeddingColumn = rows.find((row) => {
15437
+ if (!row || typeof row !== "object") return false;
15438
+ const candidate = row;
15439
+ return candidate.name === "embedding" && typeof candidate.type === "string";
15440
+ });
15441
+ const match = /^F32_BLOB\((\d+)\)$/i.exec(embeddingColumn?.type.trim() ?? "");
15442
+ if (!match) return void 0;
15443
+ return Number.parseInt(match[1], 10);
15444
+ } catch {
15445
+ return void 0;
15446
+ }
15447
+ }
15416
15448
  readUserVersion(db) {
15417
15449
  const value = db.pragma("user_version", { simple: true });
15418
15450
  if (typeof value === "number") return value;
@@ -15427,6 +15459,7 @@ var MemoryStore = class {
15427
15459
  return 0;
15428
15460
  }
15429
15461
  upsert(chunks, embeddings) {
15462
+ const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
15430
15463
  const insertChunk = this.db.prepare(`
15431
15464
  INSERT OR REPLACE INTO memory_chunks
15432
15465
  (id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
@@ -15437,7 +15470,7 @@ var MemoryStore = class {
15437
15470
  const chunk = chunks[i];
15438
15471
  try {
15439
15472
  const vectorBinding = this.toVectorBinding(embeddings[i]);
15440
- this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
15473
+ deleteChunk.run(chunk.id);
15441
15474
  insertChunk.run(
15442
15475
  chunk.id,
15443
15476
  chunk.source,
@@ -15466,11 +15499,10 @@ var MemoryStore = class {
15466
15499
  if (!embedding) throw new Error("missing embedding");
15467
15500
  if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
15468
15501
  if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
15469
- const values = Array.from(embedding, (value) => {
15470
- if (!Number.isFinite(value)) throw new Error("embedding contains non-finite value");
15471
- return value;
15472
- });
15473
- return JSON.stringify(values);
15502
+ for (let i = 0; i < embedding.length; i++) {
15503
+ if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
15504
+ }
15505
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
15474
15506
  }
15475
15507
  normalizeVectorDistance(distance) {
15476
15508
  if (distance <= 0) return 1;
@@ -15826,7 +15858,7 @@ var MemoryIndexer = class {
15826
15858
  return totals;
15827
15859
  }
15828
15860
  async search(query, topK) {
15829
- const fetchLimit = topK * 2;
15861
+ const fetchLimit = topK * FETCH_MULTIPLIER;
15830
15862
  let denseResults = [];
15831
15863
  if (this.store.vecAvailable) {
15832
15864
  try {
@@ -15837,30 +15869,50 @@ var MemoryIndexer = class {
15837
15869
  }
15838
15870
  }
15839
15871
  const bm25Results = this.store.searchBm25(query, fetchLimit);
15840
- const bm25Ids = new Set(bm25Results.map((result) => result.id));
15841
- if (bm25Ids.size > 0) {
15842
- denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
15843
- }
15872
+ const denseIds = new Set(denseResults.map((r) => r.id));
15873
+ const bm25Ids = new Set(bm25Results.map((r) => r.id));
15874
+ const unionIds = /* @__PURE__ */ new Set([...denseIds, ...bm25Ids]);
15875
+ const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
15876
+ const unionArray = [...unionIds];
15877
+ const chunks = this.store.getChunksByIds(unionArray);
15878
+ const chunkMap = new Map(chunks.map((c) => [c.id, c]));
15879
+ const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
15880
+ const recencyOrdered = [...unionArray].sort((a, b) => {
15881
+ const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
15882
+ const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
15883
+ if (mtimeB !== mtimeA) return mtimeB - mtimeA;
15884
+ return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
15885
+ });
15844
15886
  const scoreMap = /* @__PURE__ */ new Map();
15845
15887
  denseResults.forEach((r, rank) => {
15846
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
15888
+ scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
15847
15889
  });
15848
15890
  bm25Results.forEach((r, rank) => {
15849
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
15891
+ const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
15850
15892
  entry.bm25 = 1 / (RRF_K + rank + 1);
15851
15893
  scoreMap.set(r.id, entry);
15852
15894
  });
15853
- const hasDense = this.store.vecAvailable && denseResults.length > 0;
15854
- const numRetrievers = hasDense ? 2 : 1;
15855
- const maxScore = numRetrievers / (RRF_K + 1);
15895
+ recencyOrdered.forEach((id, rank) => {
15896
+ const entry = scoreMap.get(id);
15897
+ if (!entry) return;
15898
+ entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
15899
+ });
15900
+ const denseActive = denseResults.length > 0;
15901
+ const bm25Active = bm25Results.length > 0;
15902
+ const recencyActive = unionIds.size > 0;
15903
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
15856
15904
  const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
15857
15905
  id,
15858
- totalScore: scores.dense + scores.bm25,
15906
+ totalScore: scores.dense + scores.bm25 + scores.recency,
15859
15907
  hasDense: scores.dense > 0,
15860
- hasBm25: scores.bm25 > 0
15861
- })).sort((a, b) => b.totalScore - a.totalScore);
15862
- const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
15863
- const chunkMap = new Map(chunks.map((c) => [c.id, c]));
15908
+ hasBm25: scores.bm25 > 0,
15909
+ isDenseOnly: denseIds.has(id) && !bm25Ids.has(id)
15910
+ })).filter((candidate) => {
15911
+ if (!candidate.isDenseOnly) return true;
15912
+ const retrieval = denseRetrievalScore.get(candidate.id);
15913
+ if (retrieval === void 0) return true;
15914
+ return retrieval >= DENSE_SCORE_FLOOR;
15915
+ }).sort((a, b) => b.totalScore - a.totalScore);
15864
15916
  const results = [];
15865
15917
  const seenSources = /* @__PURE__ */ new Set();
15866
15918
  for (const r of ranked) {
@@ -2,7 +2,7 @@
2
2
  * Configuration utilities
3
3
  * Shared configuration constants and loaders
4
4
  */
5
- import type { MemoryConfig } from '../../services/memory/types.js';
5
+ import { type MemoryConfig } from '../../services/memory/types.js';
6
6
  import type { ConversationDigestSettings } from '../../services/digest/types.js';
7
7
  /**
8
8
  * Default file extensions - expanded to support more languages
@@ -5,7 +5,8 @@
5
5
  import * as fs from 'fs';
6
6
  import * as os from 'os';
7
7
  import * as path from 'path';
8
- import { DEFAULT_MEMORY_CONFIG } from '../../services/memory/constants.js';
8
+ import { DEFAULT_MEMORY_CONFIG, EMBEDDING_MODEL_DIMENSIONS } from '../../services/memory/constants.js';
9
+ import { EmbeddingModelName } from '../../services/memory/types.js';
9
10
  import { atomicWriteJsonFile } from '../../utils/files.js';
10
11
  /**
11
12
  * Default file extensions - expanded to support more languages
@@ -40,15 +41,28 @@ export const DEFAULT_EXTENSIONS = [
40
41
  */
41
42
  export function resolveMemoryConfig(settings, workspaceRoot) {
42
43
  const override = settings.memory ?? {};
44
+ const embeddingModel = isEmbeddingModelName(override.embeddingModel)
45
+ ? override.embeddingModel
46
+ : DEFAULT_MEMORY_CONFIG.embeddingModel;
47
+ const vectorDimension = typeof override.vectorDimension === 'number' &&
48
+ Number.isFinite(override.vectorDimension) &&
49
+ override.vectorDimension > 0
50
+ ? override.vectorDimension
51
+ : EMBEDDING_MODEL_DIMENSIONS[embeddingModel];
43
52
  return {
44
53
  ...DEFAULT_MEMORY_CONFIG,
45
54
  ...override,
46
55
  enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
56
+ embeddingModel,
57
+ vectorDimension,
47
58
  wikiDir: typeof override.wikiDir === 'string' && override.wikiDir.length > 0
48
59
  ? override.wikiDir
49
60
  : path.join(workspaceRoot, '.agent-kit', 'wiki'),
50
61
  };
51
62
  }
63
+ function isEmbeddingModelName(value) {
64
+ return value === EmbeddingModelName.TINY || value === EmbeddingModelName.BASE || value === EmbeddingModelName.LARGE;
65
+ }
52
66
  export function loadProjectSettings(projectDir) {
53
67
  const settingsPath = path.join(projectDir, '.agent-kit', 'settings.json');
54
68
  if (fs.existsSync(settingsPath)) {
package/dist/server.js CHANGED
@@ -35903,6 +35903,11 @@ import * as path2 from "path";
35903
35903
  var SOURCE_TYPES = ["digest", "concept", "entity", "preference", "wiki"];
35904
35904
 
35905
35905
  // src/services/memory/constants.ts
35906
+ var EMBEDDING_MODEL_DIMENSIONS = {
35907
+ ["tiny" /* TINY */]: 384,
35908
+ ["base" /* BASE */]: 384,
35909
+ ["large" /* LARGE */]: 768
35910
+ };
35906
35911
  var DEFAULT_MEMORY_CONFIG = {
35907
35912
  enabled: false,
35908
35913
  wikiDir: "",
@@ -35910,11 +35915,14 @@ var DEFAULT_MEMORY_CONFIG = {
35910
35915
  chunkSize: 1500,
35911
35916
  overlapLines: 2,
35912
35917
  embeddingModel: "base" /* BASE */,
35913
- vectorDimension: 384
35918
+ vectorDimension: EMBEDDING_MODEL_DIMENSIONS["base" /* BASE */]
35914
35919
  };
35915
35920
  var LOCK_RETRY_MS = 50;
35916
35921
  var LOCK_TIMEOUT_MS = 500;
35917
35922
  var RRF_K = 60;
35923
+ var FETCH_MULTIPLIER = 4;
35924
+ var RECENCY_WEIGHT = 0.5;
35925
+ var DENSE_SCORE_FLOOR = 0.2;
35918
35926
 
35919
35927
  // src/utils/files.ts
35920
35928
  import * as fs from "node:fs";
@@ -35985,13 +35993,20 @@ function tryAcquireProcessLock(lockPath) {
35985
35993
  // src/core/config/index.ts
35986
35994
  function resolveMemoryConfig(settings, workspaceRoot2) {
35987
35995
  const override = settings.memory ?? {};
35996
+ const embeddingModel = isEmbeddingModelName(override.embeddingModel) ? override.embeddingModel : DEFAULT_MEMORY_CONFIG.embeddingModel;
35997
+ const vectorDimension = typeof override.vectorDimension === "number" && Number.isFinite(override.vectorDimension) && override.vectorDimension > 0 ? override.vectorDimension : EMBEDDING_MODEL_DIMENSIONS[embeddingModel];
35988
35998
  return {
35989
35999
  ...DEFAULT_MEMORY_CONFIG,
35990
36000
  ...override,
35991
36001
  enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
36002
+ embeddingModel,
36003
+ vectorDimension,
35992
36004
  wikiDir: typeof override.wikiDir === "string" && override.wikiDir.length > 0 ? override.wikiDir : path2.join(workspaceRoot2, ".agent-kit", "wiki")
35993
36005
  };
35994
36006
  }
36007
+ function isEmbeddingModelName(value) {
36008
+ return value === "tiny" /* TINY */ || value === "base" /* BASE */ || value === "large" /* LARGE */;
36009
+ }
35995
36010
  function getGlobalSettingsFile() {
35996
36011
  return path2.join(os.homedir(), ".agent-kit", "settings.json");
35997
36012
  }
@@ -37081,7 +37096,7 @@ var MemoryIndexer = class {
37081
37096
  return totals;
37082
37097
  }
37083
37098
  async search(query, topK) {
37084
- const fetchLimit = topK * 2;
37099
+ const fetchLimit = topK * FETCH_MULTIPLIER;
37085
37100
  let denseResults = [];
37086
37101
  if (this.store.vecAvailable) {
37087
37102
  try {
@@ -37092,30 +37107,50 @@ var MemoryIndexer = class {
37092
37107
  }
37093
37108
  }
37094
37109
  const bm25Results = this.store.searchBm25(query, fetchLimit);
37095
- const bm25Ids = new Set(bm25Results.map((result) => result.id));
37096
- if (bm25Ids.size > 0) {
37097
- denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
37098
- }
37110
+ const denseIds = new Set(denseResults.map((r) => r.id));
37111
+ const bm25Ids = new Set(bm25Results.map((r) => r.id));
37112
+ const unionIds = /* @__PURE__ */ new Set([...denseIds, ...bm25Ids]);
37113
+ const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
37114
+ const unionArray = [...unionIds];
37115
+ const chunks = this.store.getChunksByIds(unionArray);
37116
+ const chunkMap = new Map(chunks.map((c) => [c.id, c]));
37117
+ const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
37118
+ const recencyOrdered = [...unionArray].sort((a, b) => {
37119
+ const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
37120
+ const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
37121
+ if (mtimeB !== mtimeA) return mtimeB - mtimeA;
37122
+ return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
37123
+ });
37099
37124
  const scoreMap = /* @__PURE__ */ new Map();
37100
37125
  denseResults.forEach((r, rank) => {
37101
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
37126
+ scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
37102
37127
  });
37103
37128
  bm25Results.forEach((r, rank) => {
37104
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
37129
+ const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
37105
37130
  entry.bm25 = 1 / (RRF_K + rank + 1);
37106
37131
  scoreMap.set(r.id, entry);
37107
37132
  });
37108
- const hasDense = this.store.vecAvailable && denseResults.length > 0;
37109
- const numRetrievers = hasDense ? 2 : 1;
37110
- const maxScore = numRetrievers / (RRF_K + 1);
37133
+ recencyOrdered.forEach((id, rank) => {
37134
+ const entry = scoreMap.get(id);
37135
+ if (!entry) return;
37136
+ entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
37137
+ });
37138
+ const denseActive = denseResults.length > 0;
37139
+ const bm25Active = bm25Results.length > 0;
37140
+ const recencyActive = unionIds.size > 0;
37141
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
37111
37142
  const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
37112
37143
  id,
37113
- totalScore: scores.dense + scores.bm25,
37144
+ totalScore: scores.dense + scores.bm25 + scores.recency,
37114
37145
  hasDense: scores.dense > 0,
37115
- hasBm25: scores.bm25 > 0
37116
- })).sort((a, b) => b.totalScore - a.totalScore);
37117
- const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
37118
- const chunkMap = new Map(chunks.map((c) => [c.id, c]));
37146
+ hasBm25: scores.bm25 > 0,
37147
+ isDenseOnly: denseIds.has(id) && !bm25Ids.has(id)
37148
+ })).filter((candidate) => {
37149
+ if (!candidate.isDenseOnly) return true;
37150
+ const retrieval = denseRetrievalScore.get(candidate.id);
37151
+ if (retrieval === void 0) return true;
37152
+ return retrieval >= DENSE_SCORE_FLOOR;
37153
+ }).sort((a, b) => b.totalScore - a.totalScore);
37119
37154
  const results = [];
37120
37155
  const seenSources = /* @__PURE__ */ new Set();
37121
37156
  for (const r of ranked) {
@@ -37178,7 +37213,7 @@ var import_stopword = __toESM(require_stopword_cjs(), 1);
37178
37213
  import Database from "libsql";
37179
37214
  import * as fs7 from "fs";
37180
37215
  import * as path9 from "path";
37181
- var SCHEMA_VERSION = 3;
37216
+ var SCHEMA_VERSION = 4;
37182
37217
  var LibsqlDatabase = Database;
37183
37218
  var StoreError = class extends Error {
37184
37219
  constructor(message, cause) {
@@ -37221,7 +37256,8 @@ var MemoryStore = class {
37221
37256
  createSchema(db) {
37222
37257
  try {
37223
37258
  const current = this.readUserVersion(db);
37224
- if (current < SCHEMA_VERSION) {
37259
+ const currentVectorDimension = current >= SCHEMA_VERSION ? this.readEmbeddingColumnDimension(db) : void 0;
37260
+ if (current < SCHEMA_VERSION || currentVectorDimension !== void 0 && currentVectorDimension !== this.config.vectorDimension) {
37225
37261
  db.exec(`
37226
37262
  DROP TRIGGER IF EXISTS memory_chunks_ai;
37227
37263
  DROP TRIGGER IF EXISTS memory_chunks_ad;
@@ -37278,6 +37314,22 @@ var MemoryStore = class {
37278
37314
  throw new StoreError("schema migration failed", err);
37279
37315
  }
37280
37316
  }
37317
+ readEmbeddingColumnDimension(db) {
37318
+ try {
37319
+ const rows = db.pragma("table_info(memory_chunks)");
37320
+ if (!Array.isArray(rows)) return void 0;
37321
+ const embeddingColumn = rows.find((row) => {
37322
+ if (!row || typeof row !== "object") return false;
37323
+ const candidate = row;
37324
+ return candidate.name === "embedding" && typeof candidate.type === "string";
37325
+ });
37326
+ const match = /^F32_BLOB\((\d+)\)$/i.exec(embeddingColumn?.type.trim() ?? "");
37327
+ if (!match) return void 0;
37328
+ return Number.parseInt(match[1], 10);
37329
+ } catch {
37330
+ return void 0;
37331
+ }
37332
+ }
37281
37333
  readUserVersion(db) {
37282
37334
  const value = db.pragma("user_version", { simple: true });
37283
37335
  if (typeof value === "number") return value;
@@ -37292,6 +37344,7 @@ var MemoryStore = class {
37292
37344
  return 0;
37293
37345
  }
37294
37346
  upsert(chunks, embeddings) {
37347
+ const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
37295
37348
  const insertChunk = this.db.prepare(`
37296
37349
  INSERT OR REPLACE INTO memory_chunks
37297
37350
  (id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
@@ -37302,7 +37355,7 @@ var MemoryStore = class {
37302
37355
  const chunk = chunks[i];
37303
37356
  try {
37304
37357
  const vectorBinding = this.toVectorBinding(embeddings[i]);
37305
- this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
37358
+ deleteChunk.run(chunk.id);
37306
37359
  insertChunk.run(
37307
37360
  chunk.id,
37308
37361
  chunk.source,
@@ -37331,11 +37384,10 @@ var MemoryStore = class {
37331
37384
  if (!embedding) throw new Error("missing embedding");
37332
37385
  if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
37333
37386
  if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
37334
- const values = Array.from(embedding, (value) => {
37335
- if (!Number.isFinite(value)) throw new Error("embedding contains non-finite value");
37336
- return value;
37337
- });
37338
- return JSON.stringify(values);
37387
+ for (let i = 0; i < embedding.length; i++) {
37388
+ if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
37389
+ }
37390
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
37339
37391
  }
37340
37392
  normalizeVectorDistance(distance) {
37341
37393
  if (distance <= 0) return 1;
@@ -1,5 +1,9 @@
1
- import { type MemoryConfig } from './types.js';
1
+ import { type MemoryConfig, EmbeddingModelName } from './types.js';
2
+ export declare const EMBEDDING_MODEL_DIMENSIONS: Record<EmbeddingModelName, number>;
2
3
  export declare const DEFAULT_MEMORY_CONFIG: MemoryConfig;
3
4
  export declare const LOCK_RETRY_MS = 50;
4
5
  export declare const LOCK_TIMEOUT_MS = 500;
5
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;
@@ -1,4 +1,9 @@
1
1
  import { EmbeddingModelName } from './types.js';
2
+ export const EMBEDDING_MODEL_DIMENSIONS = {
3
+ [EmbeddingModelName.TINY]: 384,
4
+ [EmbeddingModelName.BASE]: 384,
5
+ [EmbeddingModelName.LARGE]: 768,
6
+ };
2
7
  export const DEFAULT_MEMORY_CONFIG = {
3
8
  enabled: false,
4
9
  wikiDir: '',
@@ -6,8 +11,14 @@ export const DEFAULT_MEMORY_CONFIG = {
6
11
  chunkSize: 1500,
7
12
  overlapLines: 2,
8
13
  embeddingModel: EmbeddingModelName.BASE,
9
- vectorDimension: 384,
14
+ vectorDimension: EMBEDDING_MODEL_DIMENSIONS[EmbeddingModelName.BASE],
10
15
  };
11
16
  export const LOCK_RETRY_MS = 50;
12
17
  export const LOCK_TIMEOUT_MS = 500;
13
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;
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { chunkMarkdown } from './chunker.js';
4
- import { LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RRF_K } from './constants.js';
4
+ import { DENSE_SCORE_FLOOR, FETCH_MULTIPLIER, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RECENCY_WEIGHT, RRF_K, } from './constants.js';
5
5
  import { acquireLock, releaseLock } from '../../utils/files.js';
6
6
  export function deriveSourceType(source) {
7
7
  if (source.startsWith('compiled/provisional/conversation-digests/'))
@@ -118,7 +118,8 @@ export class MemoryIndexer {
118
118
  return totals;
119
119
  }
120
120
  async search(query, topK) {
121
- const fetchLimit = topK * 2;
121
+ // Task 2.2: overfetch to supply enough distinct sources after per-source dedup
122
+ const fetchLimit = topK * FETCH_MULTIPLIER;
122
123
  let denseResults = [];
123
124
  if (this.store.vecAvailable) {
124
125
  try {
@@ -130,32 +131,65 @@ export class MemoryIndexer {
130
131
  }
131
132
  }
132
133
  const bm25Results = this.store.searchBm25(query, fetchLimit);
133
- const bm25Ids = new Set(bm25Results.map((result) => result.id));
134
- if (bm25Ids.size > 0) {
135
- denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
136
- }
134
+ // Task 2.1: candidate set = dense ∪ bm25 — no intersection filter
135
+ const denseIds = new Set(denseResults.map((r) => r.id));
136
+ const bm25Ids = new Set(bm25Results.map((r) => r.id));
137
+ const unionIds = new Set([...denseIds, ...bm25Ids]);
138
+ // Task 2.5: preserve raw dense retrieval scores [0,1] for the precision floor check
139
+ const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
140
+ // Task 2.3: fetch all union candidates once — supplies recency mtime AND final content (no second DB call)
141
+ const unionArray = [...unionIds];
142
+ const chunks = this.store.getChunksByIds(unionArray);
143
+ const chunkMap = new Map(chunks.map((c) => [c.id, c]));
144
+ // Task 2.3: recency channel — rank union by fileMtimeAt DESC; stable tiebreak on union insertion order
145
+ const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
146
+ const recencyOrdered = [...unionArray].sort((a, b) => {
147
+ const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
148
+ const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
149
+ if (mtimeB !== mtimeA)
150
+ return mtimeB - mtimeA;
151
+ return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
152
+ });
153
+ // RRF score accumulation: dense + bm25 + weighted recency channels
137
154
  const scoreMap = new Map();
138
155
  denseResults.forEach((r, rank) => {
139
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
156
+ scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
140
157
  });
141
158
  bm25Results.forEach((r, rank) => {
142
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
159
+ const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
143
160
  entry.bm25 = 1 / (RRF_K + rank + 1);
144
161
  scoreMap.set(r.id, entry);
145
162
  });
146
- const hasDense = this.store.vecAvailable && denseResults.length > 0;
147
- const numRetrievers = hasDense ? 2 : 1;
148
- const maxScore = numRetrievers / (RRF_K + 1);
163
+ recencyOrdered.forEach((id, rank) => {
164
+ const entry = scoreMap.get(id);
165
+ if (!entry)
166
+ return;
167
+ entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
168
+ });
169
+ // Task 2.4: active-channel normalization — maxScore sums weights of channels that produced ≥1 result
170
+ const denseActive = denseResults.length > 0;
171
+ const bm25Active = bm25Results.length > 0;
172
+ const recencyActive = unionIds.size > 0;
173
+ const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
174
+ // Task 2.5: build ranked list, dropping dense-only candidates below the precision floor
149
175
  const ranked = [...scoreMap.entries()]
150
176
  .map(([id, scores]) => ({
151
177
  id,
152
- totalScore: scores.dense + scores.bm25,
178
+ totalScore: scores.dense + scores.bm25 + scores.recency,
153
179
  hasDense: scores.dense > 0,
154
180
  hasBm25: scores.bm25 > 0,
181
+ isDenseOnly: denseIds.has(id) && !bm25Ids.has(id),
155
182
  }))
183
+ .filter((candidate) => {
184
+ if (!candidate.isDenseOnly)
185
+ return true;
186
+ const retrieval = denseRetrievalScore.get(candidate.id);
187
+ // Only drop when a dense retrieval score exists and is below the floor
188
+ if (retrieval === undefined)
189
+ return true;
190
+ return retrieval >= DENSE_SCORE_FLOOR;
191
+ })
156
192
  .sort((a, b) => b.totalScore - a.totalScore);
157
- const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
158
- const chunkMap = new Map(chunks.map((c) => [c.id, c]));
159
193
  const results = [];
160
194
  const seenSources = new Set();
161
195
  for (const r of ranked) {
@@ -266,8 +266,10 @@ describe('MemoryIndexer', () => {
266
266
  fs.rmSync(testDir, { recursive: true, force: true });
267
267
  }
268
268
  });
269
- test('search does not include dense-only results when BM25 has matches', async () => {
270
- const testCfg = makeConfig('/tmp/search-dense-filter');
269
+ // Task 3.1: Rewrite — asserts union behavior (previously asserted the removed intersection filter).
270
+ // BC1 + BC2: dense-only high-score candidate surfaces; dual-channel hit ranks first.
271
+ test('search includes high-score dense-only results alongside BM25 matches', async () => {
272
+ const testCfg = makeConfig('/tmp/search-union-behavior');
271
273
  const chunks = [
272
274
  {
273
275
  id: 'preference-1',
@@ -291,7 +293,7 @@ describe('MemoryIndexer', () => {
291
293
  const fakeStore = {
292
294
  vecAvailable: true,
293
295
  searchDense: () => [
294
- { id: 'dense-only-1', score: 0.99 },
296
+ { id: 'dense-only-1', score: 0.99 }, // score >= DENSE_SCORE_FLOOR (0.2) → survives floor
295
297
  { id: 'preference-1', score: 0.98 },
296
298
  ],
297
299
  searchBm25: () => [{ id: 'preference-1', score: 1 }],
@@ -299,8 +301,361 @@ describe('MemoryIndexer', () => {
299
301
  };
300
302
  const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
301
303
  const results = await testIndexer.search('personal likes and preferences of the user', 5);
302
- assert.deepEqual(results.map((result) => result.chunk.id), ['preference-1']);
304
+ const resultIds = results.map((r) => r.chunk.id);
305
+ assert.ok(resultIds.includes('preference-1'), `Expected preference-1 in results, got: ${resultIds.join(', ')}`);
306
+ assert.ok(resultIds.includes('dense-only-1'), `Expected dense-only-1 in results, got: ${resultIds.join(', ')}`);
307
+ assert.equal(results[0].chunk.id, 'preference-1', 'dual-channel hit must rank first');
303
308
  assert.equal(results[0].retriever, 'both');
309
+ const denseOnlyResult = results.find((r) => r.chunk.id === 'dense-only-1');
310
+ assert.ok(denseOnlyResult, 'dense-only-1 must be present in results');
311
+ assert.equal(denseOnlyResult.retriever, 'dense');
312
+ });
313
+ // Task 3.2 — Regression tests BC1–BC9
314
+ // BC3: durable dual-channel memory ranks above a newer single-channel chunk despite lower recency score
315
+ test('BC3: durable dual-channel chunk ranks above newer single-channel chunk', async () => {
316
+ const testCfg = makeConfig('/tmp/bc3-durable-vs-recent');
317
+ const chunks = [
318
+ {
319
+ id: 'durable-1',
320
+ source: 'compiled/preferences.md',
321
+ heading: '',
322
+ headingLevel: 0,
323
+ content: 'I prefer dark mode',
324
+ lineStart: 1,
325
+ lineEnd: 1,
326
+ fileMtimeAt: 100, // old mtime
327
+ },
328
+ {
329
+ id: 'recent-1',
330
+ source: 'compiled/entities/meeting.md',
331
+ heading: 'Meeting',
332
+ headingLevel: 1,
333
+ content: 'Meeting notes from today',
334
+ lineStart: 1,
335
+ lineEnd: 2,
336
+ fileMtimeAt: 9_999_999_999_999, // very new mtime
337
+ },
338
+ ];
339
+ const fakeStore = {
340
+ vecAvailable: true,
341
+ searchDense: () => [{ id: 'durable-1', score: 0.9 }],
342
+ searchBm25: () => [
343
+ { id: 'durable-1', score: 1 }, // rank 0 in bm25
344
+ { id: 'recent-1', score: 0.8 }, // rank 1 in bm25 — newer mtime but single bm25 channel
345
+ ],
346
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
347
+ };
348
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
349
+ const results = await testIndexer.search('preferences', 5);
350
+ assert.ok(results.length >= 2, `Expected at least 2 results, got ${results.length}`);
351
+ assert.equal(results[0].chunk.id, 'durable-1', 'dual-channel durable memory must rank above newer single-channel chunk');
352
+ assert.equal(results[0].retriever, 'both');
353
+ const recentResult = results.find((r) => r.chunk.id === 'recent-1');
354
+ assert.ok(recentResult, 'recent-1 must still surface');
355
+ });
356
+ // BC4: search returns dense results when BM25 is empty, with recency channel applied
357
+ test('BC4: search returns dense results and applies recency when BM25 has no matches', async () => {
358
+ const testCfg = makeConfig('/tmp/bc4-dense-only-recency');
359
+ const chunks = [
360
+ {
361
+ id: 'semantic-old',
362
+ source: 'compiled/concepts/topic.md',
363
+ heading: 'Topic',
364
+ headingLevel: 1,
365
+ content: 'Concept about topic',
366
+ lineStart: 1,
367
+ lineEnd: 2,
368
+ fileMtimeAt: 1, // very old
369
+ },
370
+ {
371
+ id: 'semantic-new',
372
+ source: 'compiled/entities/recent-entity.md',
373
+ heading: 'Recent',
374
+ headingLevel: 1,
375
+ content: 'Recent entity content',
376
+ lineStart: 1,
377
+ lineEnd: 2,
378
+ fileMtimeAt: 9_999_999_999_999, // very new
379
+ },
380
+ ];
381
+ const fakeStore = {
382
+ vecAvailable: true,
383
+ searchDense: () => [
384
+ { id: 'semantic-old', score: 0.9 }, // dense rank 0
385
+ { id: 'semantic-new', score: 0.85 }, // dense rank 1
386
+ ],
387
+ searchBm25: () => [],
388
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
389
+ };
390
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
391
+ const results = await testIndexer.search('topic', 5);
392
+ assert.ok(results.length >= 2, `Expected at least 2 dense results, got ${results.length}`);
393
+ const ids = results.map((r) => r.chunk.id);
394
+ assert.ok(ids.includes('semantic-old'), 'semantic-old must be present');
395
+ assert.ok(ids.includes('semantic-new'), 'semantic-new must be present');
396
+ for (const result of results) {
397
+ assert.equal(result.retriever, 'dense', `Expected retriever 'dense', got '${result.retriever}'`);
398
+ assert.ok(result.score > 0, 'Normalized score must be positive');
399
+ }
400
+ });
401
+ // BC5: dense channel unavailable — bm25 results returned with finite normalized score, no throw
402
+ test('BC5: bm25 results are returned with normalized score when dense channel is unavailable', async () => {
403
+ const testCfg = makeConfig('/tmp/bc5-dense-unavailable');
404
+ const chunks = [
405
+ {
406
+ id: 'keyword-1',
407
+ source: 'compiled/concepts/keyword.md',
408
+ heading: 'Keyword',
409
+ headingLevel: 1,
410
+ content: 'Keyword concept content',
411
+ lineStart: 1,
412
+ lineEnd: 2,
413
+ },
414
+ ];
415
+ const fakeStore = {
416
+ vecAvailable: false,
417
+ searchBm25: () => [{ id: 'keyword-1', score: 1 }],
418
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
419
+ };
420
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
421
+ const results = await testIndexer.search('keyword', 5);
422
+ assert.equal(results.length, 1, 'Must return bm25 result when dense is unavailable');
423
+ assert.equal(results[0].chunk.id, 'keyword-1');
424
+ assert.equal(results[0].retriever, 'bm25');
425
+ assert.ok(Number.isFinite(results[0].score), 'Score must be finite');
426
+ assert.ok(results[0].score >= 0 && results[0].score <= 1, `Score must be in [0,1], got ${results[0].score}`);
427
+ });
428
+ // BC6: both channels empty → search returns []
429
+ test('BC6: search returns empty array when both dense and bm25 channels are empty', async () => {
430
+ const testCfg = makeConfig('/tmp/bc6-both-empty');
431
+ const fakeStore = {
432
+ vecAvailable: false,
433
+ searchBm25: () => [],
434
+ getChunksByIds: () => [],
435
+ };
436
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
437
+ const results = await testIndexer.search('anything', 5);
438
+ assert.deepEqual(results, []);
439
+ });
440
+ // BC7: per-source dedup yields up to topK distinct sources even when one source dominates the pool
441
+ test('BC7: per-source dedup yields topK distinct sources when one source dominates the pool', async () => {
442
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc7-overfetch-'));
443
+ const testCfg = makeConfig(path.join(testDir, 'wiki'));
444
+ const allChunks = [
445
+ {
446
+ id: 'dom-1',
447
+ source: 'compiled/entities/dominant.md',
448
+ heading: '',
449
+ headingLevel: 0,
450
+ content: 'dom 1',
451
+ lineStart: 1,
452
+ lineEnd: 1,
453
+ },
454
+ {
455
+ id: 'dom-2',
456
+ source: 'compiled/entities/dominant.md',
457
+ heading: '',
458
+ headingLevel: 0,
459
+ content: 'dom 2',
460
+ lineStart: 2,
461
+ lineEnd: 2,
462
+ },
463
+ {
464
+ id: 'dom-3',
465
+ source: 'compiled/entities/dominant.md',
466
+ heading: '',
467
+ headingLevel: 0,
468
+ content: 'dom 3',
469
+ lineStart: 3,
470
+ lineEnd: 3,
471
+ },
472
+ {
473
+ id: 'dom-4',
474
+ source: 'compiled/entities/dominant.md',
475
+ heading: '',
476
+ headingLevel: 0,
477
+ content: 'dom 4',
478
+ lineStart: 4,
479
+ lineEnd: 4,
480
+ },
481
+ {
482
+ id: 'dom-5',
483
+ source: 'compiled/entities/dominant.md',
484
+ heading: '',
485
+ headingLevel: 0,
486
+ content: 'dom 5',
487
+ lineStart: 5,
488
+ lineEnd: 5,
489
+ },
490
+ {
491
+ id: 'src-b-1',
492
+ source: 'compiled/entities/source-b.md',
493
+ heading: '',
494
+ headingLevel: 0,
495
+ content: 'source b',
496
+ lineStart: 1,
497
+ lineEnd: 1,
498
+ },
499
+ {
500
+ id: 'src-c-1',
501
+ source: 'compiled/entities/source-c.md',
502
+ heading: '',
503
+ headingLevel: 0,
504
+ content: 'source c',
505
+ lineStart: 1,
506
+ lineEnd: 1,
507
+ },
508
+ ];
509
+ const fakeStore = {
510
+ vecAvailable: false,
511
+ searchBm25: () => [
512
+ { id: 'dom-1', score: 5 },
513
+ { id: 'dom-2', score: 4.9 },
514
+ { id: 'dom-3', score: 4.8 },
515
+ { id: 'dom-4', score: 4.7 },
516
+ { id: 'dom-5', score: 4.6 },
517
+ { id: 'src-b-1', score: 3 },
518
+ { id: 'src-c-1', score: 2 },
519
+ ],
520
+ getChunksByIds: (ids) => allChunks.filter((c) => ids.includes(c.id)),
521
+ };
522
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
523
+ try {
524
+ const entitiesDir = path.join(testCfg.wikiDir, 'compiled', 'entities');
525
+ fs.mkdirSync(entitiesDir, { recursive: true });
526
+ fs.writeFileSync(path.join(entitiesDir, 'dominant.md'), 'dominant content', 'utf8');
527
+ fs.writeFileSync(path.join(entitiesDir, 'source-b.md'), 'source b content', 'utf8');
528
+ fs.writeFileSync(path.join(entitiesDir, 'source-c.md'), 'source c content', 'utf8');
529
+ const results = await testIndexer.search('query', 3);
530
+ assert.equal(results.length, 3, `Expected 3 results, got ${results.length}`);
531
+ const sources = results.map((r) => r.chunk.source);
532
+ const uniqueSources = new Set(sources);
533
+ assert.equal(uniqueSources.size, 3, 'Each result must be from a distinct source');
534
+ assert.ok(sources.includes('compiled/entities/dominant.md'), 'dominant source must be present');
535
+ assert.ok(sources.includes('compiled/entities/source-b.md'), 'source-b must be present');
536
+ assert.ok(sources.includes('compiled/entities/source-c.md'), 'source-c must be present');
537
+ }
538
+ finally {
539
+ fs.rmSync(testDir, { recursive: true, force: true });
540
+ }
541
+ });
542
+ // BC8: dense-only candidate below DENSE_SCORE_FLOOR (0.2) is dropped; at/above floor is kept; bm25-only never dropped
543
+ test('BC8: dense-only candidate below DENSE_SCORE_FLOOR is dropped; bm25-only and above-floor are kept', async () => {
544
+ const testCfg = makeConfig('/tmp/bc8-floor');
545
+ const chunks = [
546
+ {
547
+ id: 'below-floor',
548
+ source: 'compiled/entities/below.md',
549
+ heading: '',
550
+ headingLevel: 0,
551
+ content: 'below floor dense',
552
+ lineStart: 1,
553
+ lineEnd: 1,
554
+ },
555
+ {
556
+ id: 'above-floor',
557
+ source: 'compiled/entities/above.md',
558
+ heading: '',
559
+ headingLevel: 0,
560
+ content: 'above floor dense',
561
+ lineStart: 1,
562
+ lineEnd: 1,
563
+ },
564
+ {
565
+ id: 'bm25-overlap',
566
+ source: 'compiled/concepts/overlap.md',
567
+ heading: '',
568
+ headingLevel: 0,
569
+ content: 'overlap content',
570
+ lineStart: 1,
571
+ lineEnd: 1,
572
+ },
573
+ {
574
+ id: 'bm25-only',
575
+ source: 'compiled/concepts/keyword-only.md',
576
+ heading: '',
577
+ headingLevel: 0,
578
+ content: 'keyword only',
579
+ lineStart: 1,
580
+ lineEnd: 1,
581
+ },
582
+ ];
583
+ const fakeStore = {
584
+ vecAvailable: true,
585
+ // below-floor: dense-only, score 0.1 < DENSE_SCORE_FLOOR (0.2) → must be dropped
586
+ // above-floor: dense-only, score 0.9 >= DENSE_SCORE_FLOOR (0.2) → must be kept
587
+ // bm25-overlap: in both channels → never dropped regardless of dense score
588
+ searchDense: () => [
589
+ { id: 'below-floor', score: 0.1 },
590
+ { id: 'above-floor', score: 0.9 },
591
+ { id: 'bm25-overlap', score: 0.3 },
592
+ ],
593
+ searchBm25: () => [
594
+ { id: 'bm25-overlap', score: 1 },
595
+ { id: 'bm25-only', score: 0.8 },
596
+ ],
597
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
598
+ };
599
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
600
+ const results = await testIndexer.search('query', 10);
601
+ const resultIds = results.map((r) => r.chunk.id);
602
+ assert.ok(!resultIds.includes('below-floor'), `below-floor (score 0.1 < 0.2) must be dropped; got: ${resultIds.join(', ')}`);
603
+ assert.ok(resultIds.includes('above-floor'), `above-floor (score 0.9 >= 0.2) must be kept; got: ${resultIds.join(', ')}`);
604
+ assert.ok(resultIds.includes('bm25-overlap'), `bm25-overlap (dual-channel) must always be kept`);
605
+ assert.ok(resultIds.includes('bm25-only'), `bm25-only must never be dropped by the floor`);
606
+ });
607
+ // BC9: recency tiebreak is stable — repeated calls with tied/absent fileMtimeAt return identical order
608
+ test('BC9: repeated search calls with equal-mtime candidates return deterministic order', async () => {
609
+ const testCfg = makeConfig('/tmp/bc9-determinism');
610
+ const chunks = [
611
+ {
612
+ id: 'c1',
613
+ source: 'compiled/entities/c1.md',
614
+ heading: '',
615
+ headingLevel: 0,
616
+ content: 'c1 content',
617
+ lineStart: 1,
618
+ lineEnd: 1,
619
+ fileMtimeAt: 0,
620
+ },
621
+ {
622
+ id: 'c2',
623
+ source: 'compiled/entities/c2.md',
624
+ heading: '',
625
+ headingLevel: 0,
626
+ content: 'c2 content',
627
+ lineStart: 1,
628
+ lineEnd: 1,
629
+ fileMtimeAt: 0,
630
+ },
631
+ {
632
+ id: 'c3',
633
+ source: 'compiled/entities/c3.md',
634
+ heading: '',
635
+ headingLevel: 0,
636
+ content: 'c3 content',
637
+ lineStart: 1,
638
+ lineEnd: 1,
639
+ fileMtimeAt: 0,
640
+ },
641
+ ];
642
+ const fakeStore = {
643
+ vecAvailable: true,
644
+ searchDense: () => [
645
+ { id: 'c1', score: 0.9 },
646
+ { id: 'c2', score: 0.85 },
647
+ { id: 'c3', score: 0.8 },
648
+ ],
649
+ searchBm25: () => [],
650
+ getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
651
+ };
652
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
653
+ const firstCall = await testIndexer.search('query', 5);
654
+ const secondCall = await testIndexer.search('query', 5);
655
+ assert.equal(firstCall.length, secondCall.length, 'Result count must be identical across calls');
656
+ for (let idx = 0; idx < firstCall.length; idx++) {
657
+ assert.equal(firstCall[idx].chunk.id, secondCall[idx].chunk.id, `Position ${idx} must be deterministic: got '${firstCall[idx].chunk.id}' vs '${secondCall[idx].chunk.id}'`);
658
+ }
304
659
  });
305
660
  test('search still returns dense-only results when BM25 has no matches', async () => {
306
661
  const testCfg = makeConfig('/tmp/search-dense-fallback');
@@ -402,4 +402,14 @@ describe('resolveMemoryConfig', () => {
402
402
  const config = resolveMemoryConfig({ memory: { wikiDir: '/custom/wiki' } }, '/repo');
403
403
  assert.equal(config.wikiDir, '/custom/wiki');
404
404
  });
405
+ test('derives vector dimension from embedding model', () => {
406
+ const config = resolveMemoryConfig({ memory: { embeddingModel: EmbeddingModelName.LARGE } }, '/repo');
407
+ assert.equal(config.embeddingModel, EmbeddingModelName.LARGE);
408
+ assert.equal(config.vectorDimension, 768);
409
+ });
410
+ test('preserves explicit vector dimension override', () => {
411
+ const config = resolveMemoryConfig({ memory: { embeddingModel: EmbeddingModelName.LARGE, vectorDimension: 1024 } }, '/repo');
412
+ assert.equal(config.embeddingModel, EmbeddingModelName.LARGE);
413
+ assert.equal(config.vectorDimension, 1024);
414
+ });
405
415
  });
@@ -1,5 +1,5 @@
1
1
  import type { MemoryChunk, MemoryConfig, RecentSource, SourceType } from './types.js';
2
- export declare const SCHEMA_VERSION = 3;
2
+ export declare const SCHEMA_VERSION = 4;
3
3
  export declare class StoreError extends Error {
4
4
  constructor(message: string, cause?: unknown);
5
5
  }
@@ -9,6 +9,7 @@ export declare class MemoryStore {
9
9
  constructor(dbPath: string, config: MemoryConfig);
10
10
  private openDatabase;
11
11
  private createSchema;
12
+ private readEmbeddingColumnDimension;
12
13
  private readUserVersion;
13
14
  upsert(chunks: MemoryChunk[], embeddings: Float32Array[]): void;
14
15
  private toVectorBinding;
@@ -2,7 +2,7 @@ import Database from 'libsql';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { eng, removeStopwords } from 'stopword';
5
- export const SCHEMA_VERSION = 3;
5
+ export const SCHEMA_VERSION = 4;
6
6
  const LibsqlDatabase = Database;
7
7
  export class StoreError extends Error {
8
8
  constructor(message, cause) {
@@ -51,7 +51,9 @@ export class MemoryStore {
51
51
  createSchema(db) {
52
52
  try {
53
53
  const current = this.readUserVersion(db);
54
- if (current < SCHEMA_VERSION) {
54
+ const currentVectorDimension = current >= SCHEMA_VERSION ? this.readEmbeddingColumnDimension(db) : undefined;
55
+ if (current < SCHEMA_VERSION ||
56
+ (currentVectorDimension !== undefined && currentVectorDimension !== this.config.vectorDimension)) {
55
57
  db.exec(`
56
58
  DROP TRIGGER IF EXISTS memory_chunks_ai;
57
59
  DROP TRIGGER IF EXISTS memory_chunks_ad;
@@ -109,6 +111,26 @@ export class MemoryStore {
109
111
  throw new StoreError('schema migration failed', err);
110
112
  }
111
113
  }
114
+ readEmbeddingColumnDimension(db) {
115
+ try {
116
+ const rows = db.pragma('table_info(memory_chunks)');
117
+ if (!Array.isArray(rows))
118
+ return undefined;
119
+ const embeddingColumn = rows.find((row) => {
120
+ if (!row || typeof row !== 'object')
121
+ return false;
122
+ const candidate = row;
123
+ return candidate.name === 'embedding' && typeof candidate.type === 'string';
124
+ });
125
+ const match = /^F32_BLOB\((\d+)\)$/i.exec(embeddingColumn?.type.trim() ?? '');
126
+ if (!match)
127
+ return undefined;
128
+ return Number.parseInt(match[1], 10);
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
112
134
  readUserVersion(db) {
113
135
  const value = db.pragma('user_version', { simple: true });
114
136
  if (typeof value === 'number')
@@ -124,6 +146,7 @@ export class MemoryStore {
124
146
  return 0;
125
147
  }
126
148
  upsert(chunks, embeddings) {
149
+ const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
127
150
  const insertChunk = this.db.prepare(`
128
151
  INSERT OR REPLACE INTO memory_chunks
129
152
  (id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
@@ -135,7 +158,7 @@ export class MemoryStore {
135
158
  try {
136
159
  const vectorBinding = this.toVectorBinding(embeddings[i]);
137
160
  // Delete existing row first to get correct rowid
138
- this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`).run(chunk.id);
161
+ deleteChunk.run(chunk.id);
139
162
  insertChunk.run(chunk.id, chunk.source, chunk.sourceType, chunk.heading, chunk.headingLevel, chunk.content, chunk.lineStart, chunk.lineEnd, Date.now(), chunk.fileMtimeAt, vectorBinding);
140
163
  }
141
164
  catch (err) {
@@ -157,12 +180,11 @@ export class MemoryStore {
157
180
  throw new Error('missing embedding');
158
181
  if (embedding.length !== this.config.vectorDimension)
159
182
  throw new Error('embedding dimension mismatch');
160
- const values = Array.from(embedding, (value) => {
161
- if (!Number.isFinite(value))
183
+ for (let i = 0; i < embedding.length; i++) {
184
+ if (!Number.isFinite(embedding[i]))
162
185
  throw new Error('embedding contains non-finite value');
163
- return value;
164
- });
165
- return JSON.stringify(values);
186
+ }
187
+ return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
166
188
  }
167
189
  normalizeVectorDistance(distance) {
168
190
  if (distance <= 0)
@@ -387,4 +387,47 @@ describe('MemoryStore', () => {
387
387
  fs.rmSync(migrationDir, { recursive: true, force: true });
388
388
  }
389
389
  });
390
+ test('recreates schema when existing vector dimension differs from config', () => {
391
+ const dimensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-dimension-migration-'));
392
+ const dimensionDbPath = path.join(dimensionDir, 'index.db');
393
+ const baseStore = new MemoryStore(dimensionDbPath, { ...TEST_CONFIG, wikiDir: dimensionDir });
394
+ try {
395
+ baseStore.upsert([makeChunk({ id: 'old-dimension-row', source: 'dimension.md' })], [makeEmbedding(17)]);
396
+ }
397
+ finally {
398
+ baseStore.close();
399
+ }
400
+ const largeConfig = {
401
+ ...TEST_CONFIG,
402
+ wikiDir: dimensionDir,
403
+ embeddingModel: EmbeddingModelName.LARGE,
404
+ vectorDimension: 768,
405
+ };
406
+ const largeStore = new MemoryStore(dimensionDbPath, largeConfig);
407
+ try {
408
+ const verifyDb = new Database(dimensionDbPath, { readonly: true });
409
+ try {
410
+ const columns = verifyDb.prepare(`PRAGMA table_info(memory_chunks)`).all();
411
+ const embeddingColumn = columns.find((column) => column.name === 'embedding');
412
+ assert.equal(embeddingColumn?.type, 'F32_BLOB(768)');
413
+ const rowCount = verifyDb.prepare(`SELECT COUNT(*) AS count FROM memory_chunks`).get();
414
+ assert.equal(rowCount.count, 0);
415
+ }
416
+ finally {
417
+ verifyDb.close();
418
+ }
419
+ const chunk = makeChunk({
420
+ id: 'new-dimension-row',
421
+ source: 'dimension.md',
422
+ content: 'large dimension vector can be indexed',
423
+ });
424
+ largeStore.upsert([chunk], [makeEmbedding(18, 768)]);
425
+ const results = largeStore.searchBm25('large dimension vector', 5);
426
+ assert.ok(results.some((result) => result.id === 'new-dimension-row'));
427
+ }
428
+ finally {
429
+ largeStore.close();
430
+ fs.rmSync(dimensionDir, { recursive: true, force: true });
431
+ }
432
+ });
390
433
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanhnd/agent-kit",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
4
4
  "description": "Agent Kit MCP server for software development agent workflows",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",