@hanhnd/agent-kit 1.0.38 → 1.0.40

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.
@@ -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
- const indexedChunks = store.getChunksByIds(denseResults.map((denseResult) => denseResult.id));
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();
@@ -1,5 +1,7 @@
1
1
  export { MemoryStore, StoreError } from './store.js';
2
2
  export { Embedder, EmbedderError } from './embedder.js';
3
3
  export { MemoryIndexer } from './indexer.js';
4
- export type { MemoryChunk, SearchResult, MemoryConfig, IndexStats, SourceType, RecentSource } from './types.js';
4
+ export { createMemorySubsystem } from './subsystem.js';
5
+ export type { MemorySubsystem, MemorySubsystemOverrides } from './subsystem.js';
6
+ export type { MemoryChunk, SearchResult, MemoryConfig, IndexStats, SourceType, RecentSource, MemoryLifecycleState, MemoryLifecycleStatus, IndexDirectoryOptions, PreparedIndexMutation, } from './types.js';
5
7
  export { DEFAULT_MEMORY_CONFIG } from './constants.js';
@@ -1,4 +1,5 @@
1
1
  export { MemoryStore, StoreError } from './store.js';
2
2
  export { Embedder, EmbedderError } from './embedder.js';
3
3
  export { MemoryIndexer } from './indexer.js';
4
+ export { createMemorySubsystem } from './subsystem.js';
4
5
  export { DEFAULT_MEMORY_CONFIG } from './constants.js';
@@ -1,5 +1,5 @@
1
1
  import type { MemoryStore } from './store.js';
2
- import type { IndexStats, MemoryConfig, SearchResult, SourceType } from './types.js';
2
+ import type { IndexDirectoryOptions, IndexStats, MemoryConfig, SearchResult, SourceType } from './types.js';
3
3
  interface TextEmbedder {
4
4
  embed(texts: string[]): Promise<Float32Array[]>;
5
5
  initialize(): Promise<void>;
@@ -12,11 +12,10 @@ export declare class MemoryIndexer {
12
12
  private _ready;
13
13
  constructor(store: MemoryStore, embedder: TextEmbedder, config: MemoryConfig);
14
14
  indexFile(absolutePath: string): Promise<IndexStats>;
15
- private indexFileRelativeTo;
16
- indexDirectory(rootDir: string, opts?: {
17
- relativeBase?: string;
18
- excludeFiles?: string[];
19
- }): Promise<IndexStats>;
15
+ private makeSkippedMutation;
16
+ private prepareIndexFileRelativeTo;
17
+ private commitPreparedMutation;
18
+ indexDirectory(rootDir: string, opts?: IndexDirectoryOptions): Promise<IndexStats>;
20
19
  search(query: string, topK: number): Promise<SearchResult[]>;
21
20
  save(content: string): Promise<IndexStats>;
22
21
  startupIndex(): Promise<void>;
@@ -2,6 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { chunkMarkdown } from './chunker.js';
4
4
  import { DENSE_SCORE_FLOOR, FETCH_MULTIPLIER, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RECENCY_WEIGHT, RRF_K, } from './constants.js';
5
+ import { mapLimit } from '../../utils/async.js';
5
6
  import { acquireLock, releaseLock } from '../../utils/files.js';
6
7
  export function deriveSourceType(source) {
7
8
  if (source.startsWith('compiled/provisional/conversation-digests/'))
@@ -25,22 +26,32 @@ export class MemoryIndexer {
25
26
  this.config = config;
26
27
  }
27
28
  async indexFile(absolutePath) {
28
- return this.indexFileRelativeTo(absolutePath, this.config.wikiDir);
29
+ const mutation = await this.prepareIndexFileRelativeTo(absolutePath, this.config.wikiDir);
30
+ return this.commitPreparedMutation(mutation);
29
31
  }
30
- async indexFileRelativeTo(absolutePath, sourceRoot) {
32
+ makeSkippedMutation(source, skipped) {
33
+ return {
34
+ source,
35
+ chunksToUpsert: [],
36
+ embeddings: [],
37
+ idsToDelete: [],
38
+ stats: { indexed: 0, deleted: 0, skipped },
39
+ };
40
+ }
41
+ async prepareIndexFileRelativeTo(absolutePath, sourceRoot) {
31
42
  const source = path.relative(sourceRoot, absolutePath);
32
43
  const existingChunkIds = this.store.hashesBySource(source);
33
44
  let text;
34
45
  try {
35
- text = fs.readFileSync(absolutePath, 'utf8');
46
+ text = await fs.promises.readFile(absolutePath, 'utf8');
36
47
  }
37
48
  catch (err) {
38
49
  console.warn(`[memory-indexer] Cannot read file ${absolutePath}:`, err);
39
- return { indexed: 0, deleted: 0, skipped: existingChunkIds.size };
50
+ return this.makeSkippedMutation(source, existingChunkIds.size);
40
51
  }
41
52
  let fileMtimeAt;
42
53
  try {
43
- fileMtimeAt = fs.statSync(absolutePath).mtimeMs;
54
+ fileMtimeAt = (await fs.promises.stat(absolutePath)).mtimeMs;
44
55
  }
45
56
  catch (err) {
46
57
  console.warn(`[memory-indexer] Cannot stat file ${absolutePath}:`, err);
@@ -58,41 +69,60 @@ export class MemoryIndexer {
58
69
  }
59
70
  catch (err) {
60
71
  console.warn(`[memory-indexer] Embedding failed for ${absolutePath}:`, err);
61
- return { indexed: 0, deleted: 0, skipped: existingChunkIds.size };
72
+ return this.makeSkippedMutation(source, existingChunkIds.size);
62
73
  }
63
74
  }
64
- if (toIndex.length > 0) {
65
- this.store.upsert(toIndex, embeddings);
66
- }
67
- if (toDelete.length > 0) {
68
- this.store.deleteByIds(toDelete);
69
- }
70
75
  return {
71
- indexed: toIndex.length,
72
- deleted: toDelete.length,
73
- skipped: allChunks.length - toIndex.length,
76
+ source,
77
+ chunksToUpsert: toIndex,
78
+ embeddings,
79
+ idsToDelete: toDelete,
80
+ stats: {
81
+ indexed: toIndex.length,
82
+ deleted: toDelete.length,
83
+ skipped: allChunks.length - toIndex.length,
84
+ },
74
85
  };
75
86
  }
87
+ commitPreparedMutation(mutation) {
88
+ if (mutation.chunksToUpsert.length > 0) {
89
+ this.store.upsert(mutation.chunksToUpsert, mutation.embeddings);
90
+ }
91
+ if (mutation.idsToDelete.length > 0) {
92
+ this.store.deleteByIds(mutation.idsToDelete);
93
+ }
94
+ return mutation.stats;
95
+ }
76
96
  async indexDirectory(rootDir, opts = {}) {
77
97
  const totals = { indexed: 0, deleted: 0, skipped: 0 };
78
- if (!fs.existsSync(rootDir))
98
+ let rootExists;
99
+ try {
100
+ await fs.promises.stat(rootDir);
101
+ rootExists = true;
102
+ }
103
+ catch {
104
+ rootExists = false;
105
+ }
106
+ if (!rootExists)
79
107
  return totals;
80
108
  const relativeBase = opts.relativeBase ?? this.config.wikiDir;
81
109
  const excludeFiles = new Set(opts.excludeFiles ?? []);
82
110
  const files = [];
83
- const walk = (dirPath) => {
111
+ const walk = async (dirPath) => {
84
112
  let entries;
85
113
  try {
86
- entries = fs.readdirSync(dirPath, { withFileTypes: true });
114
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
87
115
  }
88
116
  catch (err) {
89
117
  console.warn(`[memory-indexer] Cannot scan directory ${dirPath}:`, err);
90
118
  return;
91
119
  }
120
+ // Sort for deterministic traversal order
121
+ entries.sort((a, b) => a.name.localeCompare(b.name));
92
122
  for (const entry of entries) {
93
123
  const entryPath = path.join(dirPath, entry.name);
94
124
  if (entry.isDirectory()) {
95
- walk(entryPath);
125
+ await walk(entryPath);
96
126
  continue;
97
127
  }
98
128
  if (entry.isFile() && /\.md$/i.test(entry.name) && !excludeFiles.has(entry.name)) {
@@ -100,7 +130,7 @@ export class MemoryIndexer {
100
130
  }
101
131
  }
102
132
  };
103
- walk(rootDir);
133
+ await walk(rootDir);
104
134
  const currentSources = new Set(files.map((file) => path.relative(relativeBase, file)));
105
135
  // Remove stale sources (deleted files or pre-migration daily-file sources)
106
136
  for (const stale of this.store.indexedSources()) {
@@ -109,8 +139,9 @@ export class MemoryIndexer {
109
139
  totals.deleted += 1;
110
140
  }
111
141
  }
112
- for (const file of files) {
113
- const stats = await this.indexFileRelativeTo(file, relativeBase);
142
+ const preparedMutations = await mapLimit(files, opts.fileConcurrency ?? 2, (file) => this.prepareIndexFileRelativeTo(file, relativeBase));
143
+ for (const mutation of preparedMutations) {
144
+ const stats = this.commitPreparedMutation(mutation);
114
145
  totals.indexed += stats.indexed;
115
146
  totals.deleted += stats.deleted;
116
147
  totals.skipped += stats.skipped;
@@ -118,99 +149,38 @@ export class MemoryIndexer {
118
149
  return totals;
119
150
  }
120
151
  async search(query, topK) {
121
- // Task 2.2: overfetch to supply enough distinct sources after per-source dedup
122
152
  const fetchLimit = topK * FETCH_MULTIPLIER;
123
- let denseResults = [];
153
+ let embedding;
124
154
  if (this.store.vecAvailable) {
125
155
  try {
126
- const queryEmbedding = await this.embedder.embed([query]);
127
- denseResults = this.store.searchDense(queryEmbedding[0], fetchLimit);
156
+ const embeddings = await this.embedder.embed([query]);
157
+ embedding = embeddings[0];
128
158
  }
129
159
  catch (err) {
130
160
  console.warn('[memory-indexer] Dense search embedding failed:', err);
131
161
  }
132
162
  }
133
- const bm25Results = this.store.searchBm25(query, fetchLimit);
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
154
- const scoreMap = new Map();
155
- denseResults.forEach((r, rank) => {
156
- scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
157
- });
158
- bm25Results.forEach((r, rank) => {
159
- const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
160
- entry.bm25 = 1 / (RRF_K + rank + 1);
161
- scoreMap.set(r.id, entry);
162
- });
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));
163
+ const rows = this.store.searchHybrid({
164
+ query,
165
+ embedding,
166
+ topK,
167
+ fetchLimit,
168
+ denseScoreFloor: DENSE_SCORE_FLOOR,
169
+ recencyWeight: RECENCY_WEIGHT,
170
+ rrfK: RRF_K,
168
171
  });
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
175
- const ranked = [...scoreMap.entries()]
176
- .map(([id, scores]) => ({
177
- id,
178
- totalScore: scores.dense + scores.bm25 + scores.recency,
179
- hasDense: scores.dense > 0,
180
- hasBm25: scores.bm25 > 0,
181
- isDenseOnly: denseIds.has(id) && !bm25Ids.has(id),
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
- })
192
- .sort((a, b) => b.totalScore - a.totalScore);
172
+ if (rows.length === 0)
173
+ return [];
193
174
  const results = [];
194
- const seenSources = new Set();
195
- for (const r of ranked) {
196
- if (results.length >= topK)
197
- break;
198
- const chunk = chunkMap.get(r.id);
199
- if (!chunk)
200
- continue;
201
- if (seenSources.has(chunk.source))
202
- continue;
203
- seenSources.add(chunk.source);
204
- const normalizedScore = maxScore > 0 ? r.totalScore / maxScore : 0;
205
- const retriever = r.hasDense && r.hasBm25 ? 'both' : r.hasDense ? 'dense' : 'bm25';
175
+ for (const row of rows) {
206
176
  let contentSource = 'file';
207
177
  try {
208
- chunk.content = fs.readFileSync(path.join(this.config.wikiDir, chunk.source), 'utf8');
178
+ row.chunk.content = await fs.promises.readFile(path.join(this.config.wikiDir, row.chunk.source), 'utf8');
209
179
  }
210
180
  catch {
211
181
  contentSource = 'fallback';
212
182
  }
213
- results.push({ chunk, score: normalizedScore, retriever, contentSource });
183
+ results.push({ chunk: row.chunk, score: row.score, retriever: row.retriever, contentSource });
214
184
  }
215
185
  return results;
216
186
  }
@@ -219,13 +189,13 @@ export class MemoryIndexer {
219
189
  const rawDir = path.join(this.config.wikiDir, 'raw');
220
190
  const savePath = path.join(rawDir, `conv_save_${datePart}.md`);
221
191
  const lockPath = `${savePath}.lock`;
222
- fs.mkdirSync(rawDir, { recursive: true });
192
+ await fs.promises.mkdir(rawDir, { recursive: true });
223
193
  const acquired = await acquireLock(lockPath, LOCK_TIMEOUT_MS, LOCK_RETRY_MS);
224
194
  if (!acquired) {
225
195
  console.warn('[memory-indexer] Could not acquire write lock, writing anyway (fail-open)');
226
196
  }
227
197
  try {
228
- fs.appendFileSync(savePath, `\n${content}\n`, 'utf8');
198
+ await fs.promises.appendFile(savePath, `\n${content}\n`, 'utf8');
229
199
  }
230
200
  finally {
231
201
  if (acquired)