@hanhnd/agent-kit 1.0.29 → 1.0.31

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import * as assert from 'node:assert/strict';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { describe, test } from 'node:test';
5
+ import { DEFAULT_MODEL_CACHE_DIR, FASTEMBED_CACHE_DIR } from '../embedder.js';
6
+ import { AGENT_KIT_HOME, CREDENTIALS_FILE } from '../../utils/paths.js';
7
+ describe('Embedder cache paths', () => {
8
+ test('uses global Agent Kit home for user-level files', () => {
9
+ assert.equal(AGENT_KIT_HOME, path.join(os.homedir(), '.agent-kit'));
10
+ assert.equal(CREDENTIALS_FILE, path.join(AGENT_KIT_HOME, 'credentials'));
11
+ assert.equal(DEFAULT_MODEL_CACHE_DIR, path.join(AGENT_KIT_HOME, 'cache', 'models'));
12
+ assert.equal(FASTEMBED_CACHE_DIR, path.join(DEFAULT_MODEL_CACHE_DIR, 'fastembed'));
13
+ });
14
+ });
@@ -268,6 +268,66 @@ describe('MemoryIndexer', () => {
268
268
  fs.rmSync(testDir, { recursive: true, force: true });
269
269
  }
270
270
  });
271
+ test('search does not include dense-only results when BM25 has matches', async () => {
272
+ const testCfg = makeConfig('/tmp/search-dense-filter');
273
+ const chunks = [
274
+ {
275
+ id: 'preference-1',
276
+ source: 'compiled/preferences.md',
277
+ heading: '',
278
+ headingLevel: 0,
279
+ content: 'I like fish',
280
+ lineStart: 1,
281
+ lineEnd: 1,
282
+ },
283
+ {
284
+ id: 'dense-only-1',
285
+ source: 'compiled/entities/worktree.md',
286
+ heading: 'Worktree',
287
+ headingLevel: 1,
288
+ content: 'Unrelated worktree lifecycle content',
289
+ lineStart: 1,
290
+ lineEnd: 2,
291
+ },
292
+ ];
293
+ const fakeStore = {
294
+ vecAvailable: true,
295
+ searchDense: () => [
296
+ { id: 'dense-only-1', score: 0.99 },
297
+ { id: 'preference-1', score: 0.98 },
298
+ ],
299
+ searchBm25: () => [{ id: 'preference-1', score: 1 }],
300
+ getChunksByIds: (ids) => chunks.filter((chunk) => ids.includes(chunk.id)),
301
+ };
302
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
303
+ const results = await testIndexer.search('personal likes and preferences of the user', 5);
304
+ assert.deepEqual(results.map((result) => result.chunk.id), ['preference-1']);
305
+ assert.equal(results[0].retriever, 'both');
306
+ });
307
+ test('search still returns dense-only results when BM25 has no matches', async () => {
308
+ const testCfg = makeConfig('/tmp/search-dense-fallback');
309
+ const chunks = [
310
+ {
311
+ id: 'semantic-1',
312
+ source: 'compiled/preferences.md',
313
+ heading: '',
314
+ headingLevel: 0,
315
+ content: 'I like fish',
316
+ lineStart: 1,
317
+ lineEnd: 1,
318
+ },
319
+ ];
320
+ const fakeStore = {
321
+ vecAvailable: true,
322
+ searchDense: () => [{ id: 'semantic-1', score: 0.99 }],
323
+ searchBm25: () => [],
324
+ getChunksByIds: (ids) => chunks.filter((chunk) => ids.includes(chunk.id)),
325
+ };
326
+ const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
327
+ const results = await testIndexer.search('favorite dish', 5);
328
+ assert.deepEqual(results.map((result) => result.chunk.id), ['semantic-1']);
329
+ assert.equal(results[0].retriever, 'dense');
330
+ });
271
331
  test('search falls back to stored chunk content when source file is missing', async () => {
272
332
  const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-fallback-'));
273
333
  const testCfg = makeConfig(path.join(testDir, 'wiki'));
@@ -72,6 +72,25 @@ describe('MemoryStore', () => {
72
72
  assert.ok(results.length > 0, 'Expected at least one BM25 result');
73
73
  assert.ok(results.some((r) => r.id === 'bm25-search-0001'), `Expected chunk id in results, got: ${results.map((r) => r.id).join(', ')}`);
74
74
  });
75
+ test('searchBm25 ignores filler words and matches preference source/content terms', () => {
76
+ const preference = makeChunk({
77
+ id: 'preference-search-0001',
78
+ source: 'compiled/preferences.md',
79
+ heading: '',
80
+ headingLevel: 0,
81
+ content: 'I like fish',
82
+ });
83
+ const unrelated = makeChunk({
84
+ id: 'preference-search-0002',
85
+ source: 'compiled/entities/worktree.md',
86
+ heading: 'Open Questions',
87
+ content: 'How should git manage worktree lifecycle decisions?',
88
+ });
89
+ store.upsert([preference, unrelated], [new Float32Array(384), new Float32Array(384)]);
90
+ const results = store.searchBm25('personal likes and preferences of the user', 5);
91
+ assert.ok(results.length > 0, 'Expected preference query to return results');
92
+ assert.equal(results[0].id, 'preference-search-0001');
93
+ });
75
94
  test('searchBm25 returns empty array for empty query', () => {
76
95
  const results = store.searchBm25(' ', 5);
77
96
  assert.deepEqual(results, []);
@@ -1,3 +1,4 @@
1
+ export { FASTEMBED_CACHE_DIR, MODEL_CACHE_DIR as DEFAULT_MODEL_CACHE_DIR } from '../utils/paths.js';
1
2
  export declare class EmbedderError extends Error {
2
3
  constructor(message: string, cause?: unknown);
3
4
  }
@@ -1,6 +1,5 @@
1
- import * as os from 'os';
2
- import * as path from 'path';
3
- const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.cache', 'fastembed');
1
+ import { FASTEMBED_CACHE_DIR } from '../utils/paths.js';
2
+ export { FASTEMBED_CACHE_DIR, MODEL_CACHE_DIR as DEFAULT_MODEL_CACHE_DIR } from '../utils/paths.js';
4
3
  export class EmbedderError extends Error {
5
4
  constructor(message, cause) {
6
5
  super(message);
@@ -50,7 +49,7 @@ export class Embedder {
50
49
  'Xenova/all-MiniLM-L6-v2': EmbeddingModel.AllMiniLML6V2,
51
50
  };
52
51
  const model = modelMap[this.modelName] ?? EmbeddingModel.BGESmallENV15;
53
- const embedder = await FlagEmbedding.init({ model: model, cacheDir: DEFAULT_CACHE_DIR, showDownloadProgress: false });
52
+ const embedder = await FlagEmbedding.init({ model: model, cacheDir: FASTEMBED_CACHE_DIR, showDownloadProgress: false });
54
53
  this.pipeline = async (texts) => {
55
54
  const result = [];
56
55
  for await (const batch of embedder.embed(texts, texts.length)) {
@@ -131,6 +131,10 @@ export class MemoryIndexer {
131
131
  }
132
132
  }
133
133
  const bm25Results = this.store.searchBm25(query, fetchLimit);
134
+ const bm25Ids = new Set(bm25Results.map((result) => result.id));
135
+ if (bm25Ids.size > 0) {
136
+ denseResults = denseResults.filter((result) => bm25Ids.has(result.id));
137
+ }
134
138
  // RRF fusion (k=60)
135
139
  const k = 60;
136
140
  const scoreMap = new Map();
@@ -1,6 +1,7 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { eng, removeStopwords } from 'stopword';
4
5
  export class StoreError extends Error {
5
6
  constructor(message, cause) {
6
7
  super(message);
@@ -58,18 +59,20 @@ export class MemoryStore {
58
59
  createSchema(db) {
59
60
  db.exec(`
60
61
  CREATE TABLE IF NOT EXISTS memory_chunks (
61
- rowid INTEGER PRIMARY KEY AUTOINCREMENT,
62
- id TEXT NOT NULL UNIQUE,
63
- source TEXT NOT NULL,
64
- heading TEXT NOT NULL DEFAULT '',
62
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ id TEXT NOT NULL UNIQUE,
64
+ source TEXT NOT NULL,
65
+ heading TEXT NOT NULL DEFAULT '',
65
66
  heading_level INTEGER NOT NULL DEFAULT 0,
66
- content TEXT NOT NULL,
67
- line_start INTEGER NOT NULL,
68
- line_end INTEGER NOT NULL,
69
- indexed_at INTEGER NOT NULL
67
+ content TEXT NOT NULL,
68
+ line_start INTEGER NOT NULL,
69
+ line_end INTEGER NOT NULL,
70
+ indexed_at INTEGER NOT NULL
70
71
  );
71
72
 
72
73
  CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
74
+ source,
75
+ heading,
73
76
  content,
74
77
  content='memory_chunks',
75
78
  content_rowid='rowid'
@@ -77,13 +80,14 @@ export class MemoryStore {
77
80
 
78
81
  CREATE TRIGGER IF NOT EXISTS memory_chunks_ai
79
82
  AFTER INSERT ON memory_chunks BEGIN
80
- INSERT INTO memory_fts(rowid, content) VALUES (new.rowid, new.content);
83
+ INSERT INTO memory_fts(rowid, source, heading, content)
84
+ VALUES (new.rowid, new.source, new.heading, new.content);
81
85
  END;
82
86
 
83
87
  CREATE TRIGGER IF NOT EXISTS memory_chunks_ad
84
88
  AFTER DELETE ON memory_chunks BEGIN
85
- INSERT INTO memory_fts(memory_fts, rowid, content)
86
- VALUES ('delete', old.rowid, old.content);
89
+ INSERT INTO memory_fts(memory_fts, rowid, source, heading, content)
90
+ VALUES ('delete', old.rowid, old.source, old.heading, old.content);
87
91
  END;
88
92
  `);
89
93
  if (this._vecAvailable) {
@@ -206,18 +210,19 @@ export class MemoryStore {
206
210
  searchBm25(query, limit) {
207
211
  if (!query.trim())
208
212
  return [];
209
- // FTS5 defaults to AND for multi-word queries; convert to OR so any term is a hit
210
- const ftsQuery = query
213
+ const tokens = query
211
214
  .trim()
212
215
  .split(/\s+/)
213
- .map((t) => t.replace(/['"*^():\-]/g, ' ').trim())
214
- .filter(Boolean)
216
+ .map((t) => t.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase())
217
+ .filter((t) => t.length > 1);
218
+ const ftsQuery = removeStopwords(tokens, eng)
219
+ .map((t) => `${t.replace(/s$/, '')}*`)
215
220
  .join(' OR ');
216
221
  if (!ftsQuery)
217
222
  return [];
218
223
  try {
219
224
  const rows = this.db
220
- .prepare(`SELECT mc.id, rank
225
+ .prepare(`SELECT mc.id, bm25(memory_fts, 0.2, 2.0, 5.0) AS rank
221
226
  FROM memory_fts
222
227
  JOIN memory_chunks mc ON mc.rowid = memory_fts.rowid
223
228
  WHERE memory_fts MATCH ?
@@ -89,7 +89,7 @@ export const FORBIDDEN_FILES = [
89
89
  '.yarnrc',
90
90
  '.netrc',
91
91
  '.gitconfig',
92
- 'credentials', // ~/.claude/credentials file
92
+ 'credentials', // ~/.agent-kit/credentials file
93
93
  ];
94
94
  /**
95
95
  * Regex patterns (case-insensitive) matched against path.basename.
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Credential loader — reads ~/.claude/credentials (INI format, like ~/.aws/credentials)
2
+ * Credential loader — reads ~/.agent-kit/credentials (INI format, like ~/.aws/credentials)
3
3
  *
4
4
  * Resolution order per key:
5
5
  * 1. process.env.KEY (explicit override / CI)
6
- * 2. ~/.claude/credentials [profile] section
6
+ * 2. ~/.agent-kit/credentials [profile] section
7
7
  * 3. undefined
8
8
  *
9
9
  * File format:
@@ -15,6 +15,6 @@
15
15
  */
16
16
  /**
17
17
  * Resolve a credential key using the priority chain:
18
- * process.env.KEY → ~/.claude/credentials [profile] → undefined
18
+ * process.env.KEY → ~/.agent-kit/credentials [profile] → undefined
19
19
  */
20
20
  export declare function getCredential(key: string): string | undefined;
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Credential loader — reads ~/.claude/credentials (INI format, like ~/.aws/credentials)
2
+ * Credential loader — reads ~/.agent-kit/credentials (INI format, like ~/.aws/credentials)
3
3
  *
4
4
  * Resolution order per key:
5
5
  * 1. process.env.KEY (explicit override / CI)
6
- * 2. ~/.claude/credentials [profile] section
6
+ * 2. ~/.agent-kit/credentials [profile] section
7
7
  * 3. undefined
8
8
  *
9
9
  * File format:
@@ -14,9 +14,7 @@
14
14
  * Active profile: KIT_PROFILE env var (defaults to "default")
15
15
  */
16
16
  import * as fs from 'fs';
17
- import * as os from 'os';
18
- import * as path from 'path';
19
- const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', 'credentials');
17
+ import { CREDENTIALS_FILE } from './paths.js';
20
18
  let cachedCredentials = null;
21
19
  /**
22
20
  * Parse a simple INI file into a map of profiles.
@@ -47,7 +45,7 @@ function parseIni(content) {
47
45
  return result;
48
46
  }
49
47
  /**
50
- * Load credentials from ~/.claude/credentials for the active profile.
48
+ * Load credentials from ~/.agent-kit/credentials for the active profile.
51
49
  * Result is cached for the lifetime of the MCP server process.
52
50
  */
53
51
  function loadCredentials() {
@@ -79,7 +77,7 @@ function loadCredentials() {
79
77
  }
80
78
  /**
81
79
  * Resolve a credential key using the priority chain:
82
- * process.env.KEY → ~/.claude/credentials [profile] → undefined
80
+ * process.env.KEY → ~/.agent-kit/credentials [profile] → undefined
83
81
  */
84
82
  export function getCredential(key) {
85
83
  if (process.env[key]) {
@@ -0,0 +1,4 @@
1
+ export declare const AGENT_KIT_HOME: string;
2
+ export declare const CREDENTIALS_FILE: string;
3
+ export declare const MODEL_CACHE_DIR: string;
4
+ export declare const FASTEMBED_CACHE_DIR: string;
@@ -0,0 +1,6 @@
1
+ import * as os from 'os';
2
+ import * as path from 'path';
3
+ export const AGENT_KIT_HOME = path.join(os.homedir(), '.agent-kit');
4
+ export const CREDENTIALS_FILE = path.join(AGENT_KIT_HOME, 'credentials');
5
+ export const MODEL_CACHE_DIR = path.join(AGENT_KIT_HOME, 'cache', 'models');
6
+ export const FASTEMBED_CACHE_DIR = path.join(MODEL_CACHE_DIR, 'fastembed');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanhnd/agent-kit",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "Agent Kit MCP server for software development agent workflows",
5
5
  "type": "module",
6
6
  "main": "dist/kit-server.js",
@@ -32,6 +32,7 @@
32
32
  "dedent": "^1.5.3",
33
33
  "fastembed": "^1.14.0",
34
34
  "sqlite-vec": "^0.1.0",
35
+ "stopword": "^3.1.5",
35
36
  "zod": "^3.24.4"
36
37
  },
37
38
  "devDependencies": {