@hanhnd/agent-kit 1.0.39 → 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.
@@ -23,6 +23,28 @@ class FailEmbedder {
23
23
  return Promise.resolve();
24
24
  }
25
25
  }
26
+ class TrackingEmbedder {
27
+ failForText;
28
+ active = 0;
29
+ maxActive = 0;
30
+ constructor(failForText = undefined) {
31
+ this.failForText = failForText;
32
+ }
33
+ async embed(texts) {
34
+ const failForText = this.failForText;
35
+ if (failForText && texts.some((text) => text.includes(failForText))) {
36
+ throw new Error('targeted embed failure');
37
+ }
38
+ this.active++;
39
+ this.maxActive = Math.max(this.maxActive, this.active);
40
+ await new Promise((resolve) => setTimeout(resolve, 10));
41
+ this.active--;
42
+ return texts.map(() => new Float32Array(384).fill(0.05));
43
+ }
44
+ initialize() {
45
+ return Promise.resolve();
46
+ }
47
+ }
26
48
  function makeConfig(wikiDir) {
27
49
  return {
28
50
  enabled: true,
@@ -378,6 +400,116 @@ describe('MemoryIndexer', () => {
378
400
  fs.rmSync(testDir, { recursive: true, force: true });
379
401
  }
380
402
  });
403
+ test('indexDirectory prepares changed files with bounded parallelism', async () => {
404
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-index-'));
405
+ const testCfg = makeConfig(path.join(testDir, 'wiki'));
406
+ const testStore = new MemoryStore(path.join(testCfg.wikiDir, 'index.db'), testCfg);
407
+ const trackingEmbedder = new TrackingEmbedder();
408
+ const testIndexer = new MemoryIndexer(testStore, trackingEmbedder, testCfg);
409
+ const compiledDir = path.join(testCfg.wikiDir, 'compiled');
410
+ try {
411
+ fs.mkdirSync(compiledDir, { recursive: true });
412
+ fs.writeFileSync(path.join(compiledDir, 'a.md'), '# A\nparallel alpha content', 'utf8');
413
+ fs.writeFileSync(path.join(compiledDir, 'b.md'), '# B\nparallel beta content', 'utf8');
414
+ fs.writeFileSync(path.join(compiledDir, 'c.md'), '# C\nparallel gamma content', 'utf8');
415
+ const stats = await testIndexer.indexDirectory(compiledDir, {
416
+ relativeBase: testCfg.wikiDir,
417
+ fileConcurrency: 2,
418
+ });
419
+ assert.ok(stats.indexed >= 3, `Expected at least 3 indexed chunks, got ${stats.indexed}`);
420
+ assert.equal(trackingEmbedder.maxActive, 2);
421
+ }
422
+ finally {
423
+ testStore.close();
424
+ fs.rmSync(testDir, { recursive: true, force: true });
425
+ }
426
+ });
427
+ test('indexDirectory serializes store mutations after parallel preparation', async () => {
428
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'serialized-writes-'));
429
+ const testCfg = makeConfig(path.join(testDir, 'wiki'));
430
+ const compiledDir = path.join(testCfg.wikiDir, 'compiled');
431
+ const calls = [];
432
+ let activeMutations = 0;
433
+ let maxActiveMutations = 0;
434
+ const fakeStore = {
435
+ vecAvailable: true,
436
+ hashesBySource: () => new Set(),
437
+ indexedSources: () => ['compiled/stale.md'],
438
+ deleteBySource: (source) => {
439
+ activeMutations++;
440
+ maxActiveMutations = Math.max(maxActiveMutations, activeMutations);
441
+ calls.push(`deleteBySource:${source}`);
442
+ activeMutations--;
443
+ },
444
+ upsert: (chunks) => {
445
+ activeMutations++;
446
+ maxActiveMutations = Math.max(maxActiveMutations, activeMutations);
447
+ calls.push(`upsert:${chunks[0]?.source}`);
448
+ activeMutations--;
449
+ },
450
+ deleteByIds: (ids) => {
451
+ activeMutations++;
452
+ maxActiveMutations = Math.max(maxActiveMutations, activeMutations);
453
+ calls.push(`deleteByIds:${ids.length}`);
454
+ activeMutations--;
455
+ },
456
+ };
457
+ try {
458
+ fs.mkdirSync(compiledDir, { recursive: true });
459
+ fs.writeFileSync(path.join(compiledDir, 'a.md'), '# A\nserialized alpha content', 'utf8');
460
+ fs.writeFileSync(path.join(compiledDir, 'b.md'), '# B\nserialized beta content', 'utf8');
461
+ const trackingEmbedder = new TrackingEmbedder();
462
+ const testIndexer = new MemoryIndexer(fakeStore, trackingEmbedder, testCfg);
463
+ await testIndexer.indexDirectory(compiledDir, {
464
+ relativeBase: testCfg.wikiDir,
465
+ fileConcurrency: 2,
466
+ });
467
+ assert.equal(maxActiveMutations, 1);
468
+ assert.equal(trackingEmbedder.maxActive, 2);
469
+ assert.deepEqual(calls.slice(0, 1), ['deleteBySource:compiled/stale.md']);
470
+ assert.ok(calls.filter((call) => call.startsWith('upsert:')).length >= 2);
471
+ }
472
+ finally {
473
+ fs.rmSync(testDir, { recursive: true, force: true });
474
+ }
475
+ });
476
+ test('indexDirectory isolates per-file embedding failure without deleting failed-file chunks', async () => {
477
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'failure-isolation-'));
478
+ const testCfg = makeConfig(path.join(testDir, 'wiki'));
479
+ const compiledDir = path.join(testCfg.wikiDir, 'compiled');
480
+ const upsertedSources = [];
481
+ const deletedIds = [];
482
+ const fakeStore = {
483
+ vecAvailable: true,
484
+ hashesBySource: (source) => (source === 'compiled/bad.md' ? new Set(['bad-old-id']) : new Set()),
485
+ indexedSources: () => ['compiled/good.md', 'compiled/bad.md'],
486
+ deleteBySource: () => undefined,
487
+ upsert: (chunks) => {
488
+ upsertedSources.push(...chunks.map((chunk) => chunk.source));
489
+ },
490
+ deleteByIds: (ids) => {
491
+ deletedIds.push(...ids);
492
+ },
493
+ };
494
+ try {
495
+ fs.mkdirSync(compiledDir, { recursive: true });
496
+ fs.writeFileSync(path.join(compiledDir, 'good.md'), '# Good\nindexable good content', 'utf8');
497
+ fs.writeFileSync(path.join(compiledDir, 'bad.md'), '# Bad\nexplode this content', 'utf8');
498
+ const testIndexer = new MemoryIndexer(fakeStore, new TrackingEmbedder('explode'), testCfg);
499
+ const stats = await testIndexer.indexDirectory(compiledDir, {
500
+ relativeBase: testCfg.wikiDir,
501
+ fileConcurrency: 2,
502
+ });
503
+ assert.ok(stats.indexed > 0, `Expected good file to be indexed, got ${stats.indexed}`);
504
+ assert.equal(stats.skipped, 1);
505
+ assert.ok(upsertedSources.includes('compiled/good.md'));
506
+ assert.ok(!upsertedSources.includes('compiled/bad.md'));
507
+ assert.deepEqual(deletedIds, []);
508
+ }
509
+ finally {
510
+ fs.rmSync(testDir, { recursive: true, force: true });
511
+ }
512
+ });
381
513
  test('deriveSourceType follows documented path conventions', () => {
382
514
  assert.equal(deriveSourceType('compiled/provisional/conversation-digests/abc.md'), 'digest');
383
515
  assert.equal(deriveSourceType('compiled/concepts/x.md'), 'concept');
@@ -45,6 +45,7 @@ function makeIndexerStub(overrides = {}) {
45
45
  return {
46
46
  search: overrides.search ?? (async () => []),
47
47
  save: overrides.save ?? (async () => ({ indexed: 1, deleted: 0, skipped: 0 })),
48
+ startupIndex: overrides.startupIndex ?? (async () => undefined),
48
49
  };
49
50
  }
50
51
  function makeStoreStub(vecAvailable, overrides = {}) {
@@ -176,6 +177,44 @@ describe('kit_memory_search', () => {
176
177
  await tools.get('kit_memory_search')({ query: 'test', top_k: 3 });
177
178
  assert.equal(capturedTopK, 3);
178
179
  });
180
+ test('returns a warming note while subsystem warmup has not completed', async () => {
181
+ const indexer = makeIndexerStub({ search: async () => [] });
182
+ const store = makeStoreStub(true);
183
+ const { server, tools } = makeMockServer();
184
+ registerMemoryTools(server, '/tmp', {
185
+ settings: { memory: { enabled: true } },
186
+ indexer,
187
+ store,
188
+ config: BASE_CONFIG,
189
+ });
190
+ const result = await tools.get('kit_memory_search')({ query: 'test' });
191
+ const text = extractText(result);
192
+ assert.ok(text.includes('Memory index is warming'), `Expected warming note, got: ${text}`);
193
+ });
194
+ test('returns degraded note after warmup failure while preserving search results', async () => {
195
+ const chunk = makeChunk({ content: 'Degraded result', source: 'compiled/entities/degraded.md' });
196
+ const indexer = makeIndexerStub({
197
+ search: async () => [{ chunk, score: 0.7, retriever: 'bm25', contentSource: 'file' }],
198
+ startupIndex: async () => {
199
+ throw new Error('warmup failed');
200
+ },
201
+ });
202
+ const store = makeStoreStub(true);
203
+ const { server, tools } = makeMockServer();
204
+ const subsystem = registerMemoryTools(server, '/tmp', {
205
+ settings: { memory: { enabled: true } },
206
+ indexer,
207
+ store,
208
+ config: BASE_CONFIG,
209
+ });
210
+ assert.ok(subsystem);
211
+ await subsystem.startWarmup();
212
+ const result = await tools.get('kit_memory_search')({ query: 'test' });
213
+ const text = extractText(result);
214
+ assert.ok(text.includes('Memory index is degraded'), `Expected degraded note, got: ${text}`);
215
+ assert.ok(text.includes('warmup failed'), `Expected warmup error, got: ${text}`);
216
+ assert.ok(text.includes('Degraded result'), 'Result must still be included during degraded state');
217
+ });
179
218
  test('falls back to config.topK when top_k is not provided', async () => {
180
219
  let capturedTopK;
181
220
  const indexer = makeIndexerStub({
@@ -214,6 +253,25 @@ describe('kit_memory_search', () => {
214
253
  assert.ok(text.includes('kit_memory_search failed'), `Expected error prefix, got: ${text}`);
215
254
  assert.ok(text.includes('search exploded'), `Expected error message, got: ${text}`);
216
255
  });
256
+ test('returns initialization failure when store construction fails', async () => {
257
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-init-failure-'));
258
+ const occupiedWikiPath = path.join(tmpDir, 'wiki-file');
259
+ fs.writeFileSync(occupiedWikiPath, 'not a directory', 'utf8');
260
+ try {
261
+ const { server, tools } = makeMockServer();
262
+ const subsystem = registerMemoryTools(server, tmpDir, {
263
+ settings: { memory: { enabled: true } },
264
+ config: { ...BASE_CONFIG, wikiDir: occupiedWikiPath },
265
+ });
266
+ assert.ok(subsystem);
267
+ const result = await tools.get('kit_memory_search')({ query: 'test' });
268
+ const text = extractText(result);
269
+ assert.ok(text.includes('Memory initialization failed'), `Expected initialization failure, got: ${text}`);
270
+ }
271
+ finally {
272
+ fs.rmSync(tmpDir, { recursive: true, force: true });
273
+ }
274
+ });
217
275
  test('description routes factual and temporal queries distinctly', () => {
218
276
  const indexer = makeIndexerStub();
219
277
  const store = makeStoreStub(true);
@@ -0,0 +1,19 @@
1
+ import { type ProjectSettings } from '../../core/config/index.js';
2
+ import { MemoryIndexer } from './indexer.js';
3
+ import { MemoryStore } from './store.js';
4
+ import type { MemoryConfig, MemoryLifecycleStatus } from './types.js';
5
+ export interface MemorySubsystemOverrides {
6
+ indexer?: MemoryIndexer;
7
+ store?: MemoryStore;
8
+ config?: MemoryConfig;
9
+ settings?: ProjectSettings;
10
+ }
11
+ export interface MemorySubsystem {
12
+ readonly config: MemoryConfig;
13
+ readonly status: MemoryLifecycleStatus;
14
+ readonly indexer: MemoryIndexer | null;
15
+ readonly store: MemoryStore | null;
16
+ startWarmup(): Promise<void>;
17
+ close(): void;
18
+ }
19
+ export declare function createMemorySubsystem(workspaceRoot: string, overrides?: MemorySubsystemOverrides): MemorySubsystem | null;
@@ -0,0 +1,67 @@
1
+ import * as path from 'path';
2
+ import { resolveMemoryConfig } from '../../core/config/index.js';
3
+ import { Embedder } from './embedder.js';
4
+ import { MemoryIndexer } from './indexer.js';
5
+ import { MemoryStore } from './store.js';
6
+ class DefaultMemorySubsystem {
7
+ config;
8
+ indexer;
9
+ store;
10
+ lifecycleStatus;
11
+ constructor(config, indexer, store, initialState, initialError) {
12
+ this.config = config;
13
+ this.indexer = indexer;
14
+ this.store = store;
15
+ this.lifecycleStatus = makeStatus(initialState, initialError);
16
+ }
17
+ get status() {
18
+ return this.lifecycleStatus;
19
+ }
20
+ async startWarmup() {
21
+ if (!this.indexer) {
22
+ this.lifecycleStatus = makeStatus('failed', this.lifecycleStatus.error ?? 'Memory indexer is unavailable');
23
+ return;
24
+ }
25
+ this.lifecycleStatus = makeStatus('warming');
26
+ try {
27
+ await this.indexer.startupIndex();
28
+ this.lifecycleStatus = makeStatus('ready');
29
+ }
30
+ catch (err) {
31
+ this.lifecycleStatus = makeStatus(this.store ? 'degraded' : 'failed', formatError(err));
32
+ }
33
+ }
34
+ close() {
35
+ this.store?.close();
36
+ }
37
+ }
38
+ function makeStatus(state, error) {
39
+ return {
40
+ state,
41
+ ready: state === 'ready',
42
+ warming: state === 'initializing' || state === 'warming',
43
+ degraded: state === 'degraded' || state === 'failed',
44
+ error,
45
+ };
46
+ }
47
+ function formatError(err) {
48
+ return err instanceof Error ? err.message : String(err);
49
+ }
50
+ export function createMemorySubsystem(workspaceRoot, overrides = {}) {
51
+ const config = overrides.config ?? resolveMemoryConfig(overrides.settings ?? {}, workspaceRoot);
52
+ if (config.enabled !== true)
53
+ return null;
54
+ let store = overrides.store ?? null;
55
+ let indexer = overrides.indexer ?? null;
56
+ try {
57
+ store = store ?? new MemoryStore(path.join(config.wikiDir, 'index.db'), config);
58
+ if (!indexer) {
59
+ const embedder = new Embedder(config.embeddingModel);
60
+ indexer = new MemoryIndexer(store, embedder, config);
61
+ }
62
+ return new DefaultMemorySubsystem(config, indexer, store, 'initializing');
63
+ }
64
+ catch (err) {
65
+ return new DefaultMemorySubsystem(config, indexer, store, 'failed', formatError(err));
66
+ }
67
+ }
@@ -66,6 +66,26 @@ export interface IndexStats {
66
66
  deleted: number;
67
67
  skipped: number;
68
68
  }
69
+ export type MemoryLifecycleState = 'disabled' | 'initializing' | 'warming' | 'ready' | 'degraded' | 'failed';
70
+ export interface MemoryLifecycleStatus {
71
+ state: MemoryLifecycleState;
72
+ ready: boolean;
73
+ warming: boolean;
74
+ degraded: boolean;
75
+ error?: string;
76
+ }
77
+ export interface IndexDirectoryOptions {
78
+ relativeBase?: string;
79
+ excludeFiles?: string[];
80
+ fileConcurrency?: number;
81
+ }
82
+ export interface PreparedIndexMutation {
83
+ source: string;
84
+ chunksToUpsert: MemoryChunk[];
85
+ embeddings: Float32Array[];
86
+ idsToDelete: string[];
87
+ stats: IndexStats;
88
+ }
69
89
  export interface RecentSource {
70
90
  source: string;
71
91
  fileMtimeAt: number;
@@ -1 +1,2 @@
1
1
  export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, createError: () => Error): Promise<T>;
2
+ export declare function mapLimit<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -13,3 +13,20 @@ export async function withTimeout(promise, timeoutMs, createError) {
13
13
  clearTimeout(timer);
14
14
  }
15
15
  }
16
+ export async function mapLimit(items, limit, worker) {
17
+ if (items.length === 0)
18
+ return [];
19
+ const concurrency = Math.max(1, Math.floor(limit));
20
+ const results = new Array(items.length);
21
+ let nextIndex = 0;
22
+ async function runWorker() {
23
+ while (nextIndex < items.length) {
24
+ const currentIndex = nextIndex;
25
+ nextIndex++;
26
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
27
+ }
28
+ }
29
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => runWorker());
30
+ await Promise.all(runners);
31
+ return results;
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import * as assert from 'node:assert/strict';
2
+ import { describe, test } from 'node:test';
3
+ import { mapLimit } from './async.js';
4
+ describe('mapLimit', () => {
5
+ test('returns an empty array for empty input', async () => {
6
+ const results = await mapLimit([], 3, async (item) => item);
7
+ assert.deepEqual(results, []);
8
+ });
9
+ test('preserves result order while workers resolve out of order', async () => {
10
+ const results = await mapLimit([30, 10, 20], 3, async (delayMs, index) => {
11
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
12
+ return index;
13
+ });
14
+ assert.deepEqual(results, [0, 1, 2]);
15
+ });
16
+ test('caps active workers at the configured limit', async () => {
17
+ let active = 0;
18
+ let maxActive = 0;
19
+ await mapLimit([1, 2, 3, 4, 5], 2, async (item) => {
20
+ active++;
21
+ maxActive = Math.max(maxActive, active);
22
+ await new Promise((resolve) => setTimeout(resolve, 5));
23
+ active--;
24
+ return item;
25
+ });
26
+ assert.equal(maxActive, 2);
27
+ });
28
+ test('clamps invalid limits to one worker', async () => {
29
+ let active = 0;
30
+ let maxActive = 0;
31
+ await mapLimit([1, 2, 3], 0, async (item) => {
32
+ active++;
33
+ maxActive = Math.max(maxActive, active);
34
+ await new Promise((resolve) => setTimeout(resolve, 5));
35
+ active--;
36
+ return item;
37
+ });
38
+ assert.equal(maxActive, 1);
39
+ });
40
+ test('rejects with the original worker error', async () => {
41
+ const expected = new Error('worker failed');
42
+ await assert.rejects(mapLimit([1, 2], 2, async (item) => {
43
+ if (item === 2)
44
+ throw expected;
45
+ return item;
46
+ }), expected);
47
+ });
48
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanhnd/agent-kit",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
4
4
  "description": "Agent Kit MCP server for software development agent workflows",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",