@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.
- package/dist/cli.js +286 -140
- package/dist/entrypoints/server.js +3 -3
- package/dist/mcp/memory.d.ts +4 -9
- package/dist/mcp/memory.js +43 -19
- package/dist/server.js +445 -212
- package/dist/services/digest/digest-processor.test.js +1 -3
- package/dist/services/memory/index.d.ts +3 -1
- package/dist/services/memory/index.js +1 -0
- package/dist/services/memory/indexer.d.ts +5 -6
- package/dist/services/memory/indexer.js +71 -101
- package/dist/services/memory/indexer.test.js +347 -572
- package/dist/services/memory/memory.test.js +58 -0
- package/dist/services/memory/store.d.ts +4 -11
- package/dist/services/memory/store.js +200 -66
- package/dist/services/memory/store.test.js +359 -108
- package/dist/services/memory/subsystem.d.ts +19 -0
- package/dist/services/memory/subsystem.js +67 -0
- package/dist/services/memory/types.d.ts +48 -0
- package/dist/utils/async.d.ts +1 -0
- package/dist/utils/async.js +17 -0
- package/dist/utils/async.test.d.ts +1 -0
- package/dist/utils/async.test.js +48 -0
- package/package.json +1 -1
|
@@ -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();
|
|
@@ -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
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
+
const mutation = await this.prepareIndexFileRelativeTo(absolutePath, this.config.wikiDir);
|
|
30
|
+
return this.commitPreparedMutation(mutation);
|
|
29
31
|
}
|
|
30
|
-
|
|
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.
|
|
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
|
|
50
|
+
return this.makeSkippedMutation(source, existingChunkIds.size);
|
|
40
51
|
}
|
|
41
52
|
let fileMtimeAt;
|
|
42
53
|
try {
|
|
43
|
-
fileMtimeAt = fs.
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
153
|
+
let embedding;
|
|
124
154
|
if (this.store.vecAvailable) {
|
|
125
155
|
try {
|
|
126
|
-
const
|
|
127
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
198
|
+
await fs.promises.appendFile(savePath, `\n${content}\n`, 'utf8');
|
|
229
199
|
}
|
|
230
200
|
finally {
|
|
231
201
|
if (acquired)
|