@synth-coder/memhub 0.2.2 → 0.2.4
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/.factory/commands/opsx-apply.md +150 -0
- package/.factory/commands/opsx-archive.md +155 -0
- package/.factory/commands/opsx-explore.md +171 -0
- package/.factory/commands/opsx-propose.md +104 -0
- package/.factory/skills/openspec-apply-change/SKILL.md +156 -0
- package/.factory/skills/openspec-archive-change/SKILL.md +114 -0
- package/.factory/skills/openspec-explore/SKILL.md +288 -0
- package/.factory/skills/openspec-propose/SKILL.md +110 -0
- package/.github/workflows/ci.yml +48 -12
- package/.github/workflows/release.yml +67 -0
- package/AGENTS.md +158 -17
- package/README.md +147 -66
- package/README.zh-CN.md +75 -23
- package/dist/src/cli/agents/claude-code.d.ts +5 -0
- package/dist/src/cli/agents/claude-code.d.ts.map +1 -0
- package/dist/src/cli/agents/claude-code.js +14 -0
- package/dist/src/cli/agents/claude-code.js.map +1 -0
- package/dist/src/cli/agents/cline.d.ts +5 -0
- package/dist/src/cli/agents/cline.d.ts.map +1 -0
- package/dist/src/cli/agents/cline.js +14 -0
- package/dist/src/cli/agents/cline.js.map +1 -0
- package/dist/src/cli/agents/codex.d.ts +5 -0
- package/dist/src/cli/agents/codex.d.ts.map +1 -0
- package/dist/src/cli/agents/codex.js +14 -0
- package/dist/src/cli/agents/codex.js.map +1 -0
- package/dist/src/cli/agents/cursor.d.ts +5 -0
- package/dist/src/cli/agents/cursor.d.ts.map +1 -0
- package/dist/src/cli/agents/cursor.js +14 -0
- package/dist/src/cli/agents/cursor.js.map +1 -0
- package/dist/src/cli/agents/factory-droid.d.ts +5 -0
- package/dist/src/cli/agents/factory-droid.d.ts.map +1 -0
- package/dist/src/cli/agents/factory-droid.js +14 -0
- package/dist/src/cli/agents/factory-droid.js.map +1 -0
- package/dist/src/cli/agents/gemini-cli.d.ts +5 -0
- package/dist/src/cli/agents/gemini-cli.d.ts.map +1 -0
- package/dist/src/cli/agents/gemini-cli.js +14 -0
- package/dist/src/cli/agents/gemini-cli.js.map +1 -0
- package/dist/src/cli/agents/index.d.ts +14 -0
- package/dist/src/cli/agents/index.d.ts.map +1 -0
- package/dist/src/cli/agents/index.js +30 -0
- package/dist/src/cli/agents/index.js.map +1 -0
- package/dist/src/cli/agents/windsurf.d.ts +5 -0
- package/dist/src/cli/agents/windsurf.d.ts.map +1 -0
- package/dist/src/cli/agents/windsurf.js +14 -0
- package/dist/src/cli/agents/windsurf.js.map +1 -0
- package/dist/src/cli/index.d.ts +8 -0
- package/dist/src/cli/index.d.ts.map +1 -0
- package/dist/src/cli/index.js +168 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/cli/init.d.ts +34 -0
- package/dist/src/cli/init.d.ts.map +1 -0
- package/dist/src/cli/init.js +160 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.d.ts +29 -0
- package/dist/src/cli/instructions.d.ts.map +1 -0
- package/dist/src/cli/instructions.js +141 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/types.d.ts +22 -0
- package/dist/src/cli/types.d.ts.map +1 -0
- package/dist/src/cli/types.js +86 -0
- package/dist/src/cli/types.js.map +1 -0
- package/dist/src/contracts/schemas.js.map +1 -1
- package/dist/src/server/mcp-server.d.ts +8 -0
- package/dist/src/server/mcp-server.d.ts.map +1 -1
- package/dist/src/server/mcp-server.js +30 -16
- package/dist/src/server/mcp-server.js.map +1 -1
- package/dist/src/services/embedding-service.d.ts.map +1 -1
- package/dist/src/services/embedding-service.js +1 -1
- package/dist/src/services/embedding-service.js.map +1 -1
- package/dist/src/services/memory-service.d.ts +1 -0
- package/dist/src/services/memory-service.d.ts.map +1 -1
- package/dist/src/services/memory-service.js +125 -82
- package/dist/src/services/memory-service.js.map +1 -1
- package/dist/src/storage/markdown-storage.d.ts.map +1 -1
- package/dist/src/storage/markdown-storage.js +1 -1
- package/dist/src/storage/markdown-storage.js.map +1 -1
- package/dist/src/storage/vector-index.d.ts.map +1 -1
- package/dist/src/storage/vector-index.js +4 -5
- package/dist/src/storage/vector-index.js.map +1 -1
- package/docs/README.md +21 -0
- package/docs/mcp-tools.md +136 -0
- package/docs/user-guide.md +182 -0
- package/package.json +22 -19
- package/src/cli/agents/claude-code.ts +14 -0
- package/src/cli/agents/cline.ts +14 -0
- package/src/cli/agents/codex.ts +14 -0
- package/src/cli/agents/cursor.ts +14 -0
- package/src/cli/agents/factory-droid.ts +14 -0
- package/src/cli/agents/gemini-cli.ts +14 -0
- package/src/cli/agents/index.ts +36 -0
- package/src/cli/agents/windsurf.ts +14 -0
- package/src/cli/index.ts +192 -0
- package/src/cli/init.ts +218 -0
- package/src/cli/instructions.ts +156 -0
- package/src/cli/types.ts +112 -0
- package/src/contracts/mcp.ts +1 -1
- package/src/contracts/schemas.ts +4 -4
- package/src/contracts/types.ts +4 -4
- package/src/server/mcp-server.ts +36 -29
- package/src/services/embedding-service.ts +80 -80
- package/src/services/memory-service.ts +142 -107
- package/src/storage/markdown-storage.ts +1 -9
- package/src/storage/vector-index.ts +117 -118
- package/test/cli/init.test.ts +380 -0
- package/test/server/mcp-server.test.ts +45 -3
- package/test/services/memory-service.test.ts +16 -4
- package/test/storage/frontmatter-parser.test.ts +1 -1
- package/test/storage/markdown-storage.test.ts +19 -10
- package/test/storage/vector-index.test.ts +129 -133
- package/test/utils/slugify.test.ts +5 -1
- package/docs/architecture.md +0 -349
- package/docs/contracts.md +0 -119
- package/docs/prompt-template.md +0 -79
- package/docs/proposals/mcp-typescript-sdk-refactor.md +0 -568
- package/docs/proposals/proposal-close-gates.md +0 -58
- package/docs/tool-calling-policy.md +0 -107
|
@@ -15,7 +15,7 @@ const TABLE_NAME = 'memories';
|
|
|
15
15
|
|
|
16
16
|
/** Escape single quotes in id strings to prevent SQL injection */
|
|
17
17
|
function escapeId(id: string): string {
|
|
18
|
-
|
|
18
|
+
return id.replace(/'/g, "''");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -23,20 +23,20 @@ function escapeId(id: string): string {
|
|
|
23
23
|
* The `vector` field is the only one required by LanceDB; all others are metadata filters.
|
|
24
24
|
*/
|
|
25
25
|
export interface VectorRow {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
id: string;
|
|
27
|
+
vector: number[];
|
|
28
|
+
title: string;
|
|
29
|
+
category: string;
|
|
30
|
+
tags: string; // JSON-serialised string[]
|
|
31
|
+
importance: number;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export interface VectorSearchResult {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
id: string;
|
|
38
|
+
/** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
|
|
39
|
+
_distance: number;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -44,117 +44,116 @@ export interface VectorSearchResult {
|
|
|
44
44
|
* Data lives at `{storagePath}/.lancedb/`.
|
|
45
45
|
*/
|
|
46
46
|
export class VectorIndex {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
private readonly dbPath: string;
|
|
48
|
+
private db: lancedb.Connection | null = null;
|
|
49
|
+
private table: lancedb.Table | null = null;
|
|
50
|
+
private initPromise: Promise<void> | null = null;
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
/** Idempotent initialisation — safe to call multiple times. */
|
|
57
|
-
async initialize(): Promise<void> {
|
|
58
|
-
if (this.table) return;
|
|
59
|
-
|
|
60
|
-
if (!this.initPromise) {
|
|
61
|
-
this.initPromise = this._init();
|
|
62
|
-
}
|
|
63
|
-
await this.initPromise;
|
|
64
|
-
}
|
|
52
|
+
constructor(storagePath: string) {
|
|
53
|
+
this.dbPath = join(storagePath, '.lancedb');
|
|
54
|
+
}
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await access(this.dbPath, constants.F_OK);
|
|
70
|
-
} catch {
|
|
71
|
-
await mkdir(this.dbPath, { recursive: true });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
this.db = await lancedb.connect(this.dbPath);
|
|
75
|
-
|
|
76
|
-
const existingTables = await this.db.tableNames();
|
|
77
|
-
if (existingTables.includes(TABLE_NAME)) {
|
|
78
|
-
this.table = await this.db.openTable(TABLE_NAME);
|
|
79
|
-
} else {
|
|
80
|
-
// Create table with a dummy row so schema is established, then delete it
|
|
81
|
-
const dummy: VectorRow = {
|
|
82
|
-
id: '__init__',
|
|
83
|
-
vector: new Array(384).fill(0) as number[],
|
|
84
|
-
title: '',
|
|
85
|
-
category: '',
|
|
86
|
-
tags: '[]',
|
|
87
|
-
importance: 0,
|
|
88
|
-
createdAt: '',
|
|
89
|
-
updatedAt: '',
|
|
90
|
-
};
|
|
91
|
-
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
92
|
-
// Cast is safe here as VectorRow is a subset of Record<string, unknown>
|
|
93
|
-
this.table = await this.db.createTable(TABLE_NAME, [dummy as unknown as Record<string, unknown>]);
|
|
94
|
-
await this.table.delete(`id = '__init__'`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Upserts a memory row into the index.
|
|
100
|
-
* LanceDB doesn't have a native upsert so we delete-then-add.
|
|
101
|
-
*/
|
|
102
|
-
async upsert(memory: Memory, vector: number[]): Promise<void> {
|
|
103
|
-
await this.initialize();
|
|
104
|
-
const table = this.table!;
|
|
105
|
-
|
|
106
|
-
// Remove existing row (if any)
|
|
107
|
-
await table.delete(`id = '${escapeId(memory.id)}'`);
|
|
108
|
-
|
|
109
|
-
const row: VectorRow = {
|
|
110
|
-
id: memory.id,
|
|
111
|
-
vector,
|
|
112
|
-
title: memory.title,
|
|
113
|
-
category: memory.category,
|
|
114
|
-
tags: JSON.stringify(memory.tags),
|
|
115
|
-
importance: memory.importance,
|
|
116
|
-
createdAt: memory.createdAt,
|
|
117
|
-
updatedAt: memory.updatedAt,
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
121
|
-
await table.add([row as unknown as Record<string, unknown>]);
|
|
122
|
-
}
|
|
56
|
+
/** Idempotent initialisation — safe to call multiple times. */
|
|
57
|
+
async initialize(): Promise<void> {
|
|
58
|
+
if (this.table) return;
|
|
123
59
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
*/
|
|
127
|
-
async delete(id: string): Promise<void> {
|
|
128
|
-
await this.initialize();
|
|
129
|
-
await this.table!.delete(`id = '${escapeId(id)}'`);
|
|
60
|
+
if (!this.initPromise) {
|
|
61
|
+
this.initPromise = this._init();
|
|
130
62
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await this.initialize();
|
|
141
|
-
|
|
142
|
-
const results = await this.table!
|
|
143
|
-
.vectorSearch(vector)
|
|
144
|
-
.limit(limit)
|
|
145
|
-
.toArray();
|
|
146
|
-
|
|
147
|
-
return results.map((row: Record<string, unknown>) => ({
|
|
148
|
-
id: row['id'] as string,
|
|
149
|
-
_distance: row['_distance'] as number,
|
|
150
|
-
}));
|
|
63
|
+
await this.initPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async _init(): Promise<void> {
|
|
67
|
+
// Ensure the directory exists
|
|
68
|
+
try {
|
|
69
|
+
await access(this.dbPath, constants.F_OK);
|
|
70
|
+
} catch {
|
|
71
|
+
await mkdir(this.dbPath, { recursive: true });
|
|
151
72
|
}
|
|
152
73
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
74
|
+
this.db = await lancedb.connect(this.dbPath);
|
|
75
|
+
|
|
76
|
+
const existingTables = await this.db.tableNames();
|
|
77
|
+
if (existingTables.includes(TABLE_NAME)) {
|
|
78
|
+
this.table = await this.db.openTable(TABLE_NAME);
|
|
79
|
+
} else {
|
|
80
|
+
// Create table with a dummy row so schema is established, then delete it
|
|
81
|
+
const dummy: VectorRow = {
|
|
82
|
+
id: '__init__',
|
|
83
|
+
vector: new Array(384).fill(0) as number[],
|
|
84
|
+
title: '',
|
|
85
|
+
category: '',
|
|
86
|
+
tags: '[]',
|
|
87
|
+
importance: 0,
|
|
88
|
+
createdAt: '',
|
|
89
|
+
updatedAt: '',
|
|
90
|
+
};
|
|
91
|
+
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
92
|
+
// Cast is safe here as VectorRow is a subset of Record<string, unknown>
|
|
93
|
+
this.table = await this.db.createTable(TABLE_NAME, [
|
|
94
|
+
dummy as unknown as Record<string, unknown>,
|
|
95
|
+
]);
|
|
96
|
+
await this.table.delete(`id = '__init__'`);
|
|
159
97
|
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Upserts a memory row into the index.
|
|
102
|
+
* LanceDB doesn't have a native upsert so we delete-then-add.
|
|
103
|
+
*/
|
|
104
|
+
async upsert(memory: Memory, vector: number[]): Promise<void> {
|
|
105
|
+
await this.initialize();
|
|
106
|
+
const table = this.table!;
|
|
107
|
+
|
|
108
|
+
// Remove existing row (if any)
|
|
109
|
+
await table.delete(`id = '${escapeId(memory.id)}'`);
|
|
110
|
+
|
|
111
|
+
const row: VectorRow = {
|
|
112
|
+
id: memory.id,
|
|
113
|
+
vector,
|
|
114
|
+
title: memory.title,
|
|
115
|
+
category: memory.category,
|
|
116
|
+
tags: JSON.stringify(memory.tags),
|
|
117
|
+
importance: memory.importance,
|
|
118
|
+
createdAt: memory.createdAt,
|
|
119
|
+
updatedAt: memory.updatedAt,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
|
|
123
|
+
await table.add([row as unknown as Record<string, unknown>]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Removes a memory from the index by ID.
|
|
128
|
+
*/
|
|
129
|
+
async delete(id: string): Promise<void> {
|
|
130
|
+
await this.initialize();
|
|
131
|
+
await this.table!.delete(`id = '${escapeId(id)}'`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Searches for the nearest neighbours to `vector`.
|
|
136
|
+
*
|
|
137
|
+
* @param vector - Query embedding (must be 384-dim)
|
|
138
|
+
* @param limit - Max results to return
|
|
139
|
+
* @returns Array ordered by ascending distance (most similar first)
|
|
140
|
+
*/
|
|
141
|
+
async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
|
|
142
|
+
await this.initialize();
|
|
143
|
+
|
|
144
|
+
const results = await this.table!.vectorSearch(vector).limit(limit).toArray();
|
|
145
|
+
|
|
146
|
+
return results.map((row: Record<string, unknown>) => ({
|
|
147
|
+
id: row['id'] as string,
|
|
148
|
+
_distance: row['_distance'] as number,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Returns the number of rows in the index.
|
|
154
|
+
*/
|
|
155
|
+
async count(): Promise<number> {
|
|
156
|
+
await this.initialize();
|
|
157
|
+
return this.table!.countRows();
|
|
158
|
+
}
|
|
160
159
|
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CLI init command
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { initAgent } from '../../src/cli/init.js';
|
|
9
|
+
import { AGENTS, type AgentType } from '../../src/cli/types.js';
|
|
10
|
+
import {
|
|
11
|
+
extractMemHubVersion,
|
|
12
|
+
needsUpdate,
|
|
13
|
+
updateInstructionsContent,
|
|
14
|
+
} from '../../src/cli/instructions.js';
|
|
15
|
+
|
|
16
|
+
const TEST_DIR = join(process.cwd(), 'test-temp-cli');
|
|
17
|
+
|
|
18
|
+
describe('CLI Init Command', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
if (!existsSync(TEST_DIR)) {
|
|
21
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (existsSync(TEST_DIR)) {
|
|
27
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('initAgent', () => {
|
|
32
|
+
it('should create local config and instructions for cursor', () => {
|
|
33
|
+
const result = initAgent({
|
|
34
|
+
agent: 'cursor',
|
|
35
|
+
local: true,
|
|
36
|
+
projectPath: TEST_DIR,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(result.success).toBe(true);
|
|
40
|
+
if (result.success) {
|
|
41
|
+
expect(result.agent.id).toBe('cursor');
|
|
42
|
+
expect(result.configPath).toBe('.cursor/mcp.json');
|
|
43
|
+
expect(result.instructionsPath).toBe('.cursorrules');
|
|
44
|
+
expect(existsSync(join(TEST_DIR, '.cursor/mcp.json'))).toBe(true);
|
|
45
|
+
expect(existsSync(join(TEST_DIR, '.cursorrules'))).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should create local config and instructions for claude-code', () => {
|
|
50
|
+
const result = initAgent({
|
|
51
|
+
agent: 'claude-code',
|
|
52
|
+
local: true,
|
|
53
|
+
projectPath: TEST_DIR,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.success).toBe(true);
|
|
57
|
+
if (result.success) {
|
|
58
|
+
expect(result.agent.id).toBe('claude-code');
|
|
59
|
+
expect(result.configPath).toBe('.mcp.json');
|
|
60
|
+
expect(result.instructionsPath).toBe('CLAUDE.md');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should create local config and instructions for cline', () => {
|
|
65
|
+
const result = initAgent({
|
|
66
|
+
agent: 'cline',
|
|
67
|
+
local: true,
|
|
68
|
+
projectPath: TEST_DIR,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
if (result.success) {
|
|
73
|
+
expect(result.agent.id).toBe('cline');
|
|
74
|
+
expect(result.configPath).toBe('.cline/mcp.json');
|
|
75
|
+
expect(result.instructionsPath).toBe('.clinerules');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should create local config and instructions for windsurf', () => {
|
|
80
|
+
const result = initAgent({
|
|
81
|
+
agent: 'windsurf',
|
|
82
|
+
local: true,
|
|
83
|
+
projectPath: TEST_DIR,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.success).toBe(true);
|
|
87
|
+
if (result.success) {
|
|
88
|
+
expect(result.agent.id).toBe('windsurf');
|
|
89
|
+
expect(result.configPath).toBe('.codeium/windsurf/mcp_config.json');
|
|
90
|
+
expect(result.instructionsPath).toBe('.windsurfrules');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should create local config and instructions for factory-droid', () => {
|
|
95
|
+
const result = initAgent({
|
|
96
|
+
agent: 'factory-droid',
|
|
97
|
+
local: true,
|
|
98
|
+
projectPath: TEST_DIR,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.success).toBe(true);
|
|
102
|
+
if (result.success) {
|
|
103
|
+
expect(result.agent.id).toBe('factory-droid');
|
|
104
|
+
expect(result.configPath).toBe('.factory/mcp.json');
|
|
105
|
+
expect(result.instructionsPath).toBe('.factory/AGENTS.md');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should create local config and instructions for gemini-cli', () => {
|
|
110
|
+
const result = initAgent({
|
|
111
|
+
agent: 'gemini-cli',
|
|
112
|
+
local: true,
|
|
113
|
+
projectPath: TEST_DIR,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
if (result.success) {
|
|
118
|
+
expect(result.agent.id).toBe('gemini-cli');
|
|
119
|
+
expect(result.configPath).toBe('.gemini/settings.json');
|
|
120
|
+
expect(result.instructionsPath).toBe('GEMINI.md');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should create local config and instructions for codex', () => {
|
|
125
|
+
const result = initAgent({
|
|
126
|
+
agent: 'codex',
|
|
127
|
+
local: true,
|
|
128
|
+
projectPath: TEST_DIR,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.success).toBe(true);
|
|
132
|
+
if (result.success) {
|
|
133
|
+
expect(result.agent.id).toBe('codex');
|
|
134
|
+
expect(result.configPath).toBe('.codex/config.toml');
|
|
135
|
+
expect(result.instructionsPath).toBe('AGENTS.md');
|
|
136
|
+
expect(existsSync(join(TEST_DIR, '.codex/config.toml'))).toBe(true);
|
|
137
|
+
expect(existsSync(join(TEST_DIR, 'AGENTS.md'))).toBe(true);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should fail for unknown agent', () => {
|
|
142
|
+
const result = initAgent({
|
|
143
|
+
agent: 'unknown-agent' as AgentType,
|
|
144
|
+
local: true,
|
|
145
|
+
projectPath: TEST_DIR,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result.success).toBe(false);
|
|
149
|
+
if (!result.success) {
|
|
150
|
+
expect(result.error).toContain('Unknown agent');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should fail if memhub already configured without force', () => {
|
|
155
|
+
// First init
|
|
156
|
+
initAgent({
|
|
157
|
+
agent: 'claude-code',
|
|
158
|
+
local: true,
|
|
159
|
+
projectPath: TEST_DIR,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Second init without force
|
|
163
|
+
const result = initAgent({
|
|
164
|
+
agent: 'claude-code',
|
|
165
|
+
local: true,
|
|
166
|
+
projectPath: TEST_DIR,
|
|
167
|
+
force: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.success).toBe(false);
|
|
171
|
+
if (!result.success) {
|
|
172
|
+
expect(result.error).toContain('already configured');
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should update config with force', () => {
|
|
177
|
+
// First init
|
|
178
|
+
initAgent({
|
|
179
|
+
agent: 'claude-code',
|
|
180
|
+
local: true,
|
|
181
|
+
projectPath: TEST_DIR,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Second init with force
|
|
185
|
+
const result = initAgent({
|
|
186
|
+
agent: 'claude-code',
|
|
187
|
+
local: true,
|
|
188
|
+
projectPath: TEST_DIR,
|
|
189
|
+
force: true,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result.success).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should generate valid JSON config', () => {
|
|
196
|
+
initAgent({
|
|
197
|
+
agent: 'cursor',
|
|
198
|
+
local: true,
|
|
199
|
+
projectPath: TEST_DIR,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const configPath = join(TEST_DIR, '.cursor/mcp.json');
|
|
203
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
204
|
+
const config = JSON.parse(content);
|
|
205
|
+
|
|
206
|
+
expect(config).toHaveProperty('mcpServers');
|
|
207
|
+
expect(config.mcpServers).toHaveProperty('memhub');
|
|
208
|
+
expect(config.mcpServers.memhub).toHaveProperty('command');
|
|
209
|
+
expect(config.mcpServers.memhub.command).toBe('npx');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should generate instructions with version tag', () => {
|
|
213
|
+
initAgent({
|
|
214
|
+
agent: 'claude-code',
|
|
215
|
+
local: true,
|
|
216
|
+
projectPath: TEST_DIR,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const instructionsPath = join(TEST_DIR, 'CLAUDE.md');
|
|
220
|
+
const content = readFileSync(instructionsPath, 'utf-8');
|
|
221
|
+
|
|
222
|
+
expect(content).toContain('<!-- MEMHUB:v');
|
|
223
|
+
expect(content).toContain('<!-- MEMHUB:END -->');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should merge with existing config preserving other servers', () => {
|
|
227
|
+
// Create existing config with other servers
|
|
228
|
+
const configDir = join(TEST_DIR, '.mcp.json');
|
|
229
|
+
const existingConfig = {
|
|
230
|
+
mcpServers: {
|
|
231
|
+
github: {
|
|
232
|
+
command: 'npx',
|
|
233
|
+
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
writeFileSync(configDir, JSON.stringify(existingConfig, null, 2), 'utf-8');
|
|
238
|
+
|
|
239
|
+
// Run init
|
|
240
|
+
const result = initAgent({
|
|
241
|
+
agent: 'claude-code',
|
|
242
|
+
local: true,
|
|
243
|
+
projectPath: TEST_DIR,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.success).toBe(true);
|
|
247
|
+
|
|
248
|
+
// Verify both servers exist
|
|
249
|
+
const content = readFileSync(configDir, 'utf-8');
|
|
250
|
+
const config = JSON.parse(content);
|
|
251
|
+
|
|
252
|
+
expect(config.mcpServers).toHaveProperty('github');
|
|
253
|
+
expect(config.mcpServers).toHaveProperty('memhub');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should fail if memhub already in config without force', () => {
|
|
257
|
+
// Create config with memhub already configured
|
|
258
|
+
const configDir = join(TEST_DIR, '.mcp.json');
|
|
259
|
+
const existingConfig = {
|
|
260
|
+
mcpServers: {
|
|
261
|
+
memhub: {
|
|
262
|
+
command: 'npx',
|
|
263
|
+
args: ['-y', '@synth-coder/memhub'],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
writeFileSync(configDir, JSON.stringify(existingConfig, null, 2), 'utf-8');
|
|
268
|
+
|
|
269
|
+
// Run init without force
|
|
270
|
+
const result = initAgent({
|
|
271
|
+
agent: 'claude-code',
|
|
272
|
+
local: true,
|
|
273
|
+
projectPath: TEST_DIR,
|
|
274
|
+
force: false,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.success).toBe(false);
|
|
278
|
+
if (!result.success) {
|
|
279
|
+
expect(result.error).toContain('already configured');
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should update memhub with force preserving other servers', () => {
|
|
284
|
+
// Create config with existing servers including memhub
|
|
285
|
+
const configDir = join(TEST_DIR, '.mcp.json');
|
|
286
|
+
const existingConfig = {
|
|
287
|
+
mcpServers: {
|
|
288
|
+
github: {
|
|
289
|
+
command: 'npx',
|
|
290
|
+
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
291
|
+
},
|
|
292
|
+
memhub: {
|
|
293
|
+
command: 'old-command',
|
|
294
|
+
args: [],
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
writeFileSync(configDir, JSON.stringify(existingConfig, null, 2), 'utf-8');
|
|
299
|
+
|
|
300
|
+
// Run init with force
|
|
301
|
+
const result = initAgent({
|
|
302
|
+
agent: 'claude-code',
|
|
303
|
+
local: true,
|
|
304
|
+
projectPath: TEST_DIR,
|
|
305
|
+
force: true,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(result.success).toBe(true);
|
|
309
|
+
|
|
310
|
+
// Verify github preserved, memhub updated
|
|
311
|
+
const content = readFileSync(configDir, 'utf-8');
|
|
312
|
+
const config = JSON.parse(content);
|
|
313
|
+
|
|
314
|
+
expect(config.mcpServers).toHaveProperty('github');
|
|
315
|
+
expect(config.mcpServers).toHaveProperty('memhub');
|
|
316
|
+
expect(config.mcpServers.memhub.command).toBe('npx');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('Instructions Update', () => {
|
|
321
|
+
it('should prepend instructions to empty file', () => {
|
|
322
|
+
const result = updateInstructionsContent('', AGENTS[1]); // claude-code
|
|
323
|
+
|
|
324
|
+
expect(result.updated).toBe(true);
|
|
325
|
+
expect(result.content).toContain('<!-- MEMHUB:v');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should prepend instructions to existing content', () => {
|
|
329
|
+
const existing = '# My Project\n\nSome instructions';
|
|
330
|
+
const result = updateInstructionsContent(existing, AGENTS[1]);
|
|
331
|
+
|
|
332
|
+
expect(result.updated).toBe(true);
|
|
333
|
+
expect(result.content).toContain('<!-- MEMHUB:v');
|
|
334
|
+
expect(result.content).toContain('# My Project');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should not update if already current version', () => {
|
|
338
|
+
const content = '<!-- MEMHUB:v0.2.3:START -->\nContent\n<!-- MEMHUB:END -->';
|
|
339
|
+
const result = updateInstructionsContent(content, AGENTS[1]);
|
|
340
|
+
|
|
341
|
+
expect(result.updated).toBe(false);
|
|
342
|
+
expect(result.reason).toBe('Already up to date');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should extract version correctly', () => {
|
|
346
|
+
expect(extractMemHubVersion('<!-- MEMHUB:v0.2.3:START -->')).toBe('0.2.3');
|
|
347
|
+
expect(extractMemHubVersion('No version here')).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should detect when update is needed', () => {
|
|
351
|
+
expect(needsUpdate('No MemHub content')).toBe(true);
|
|
352
|
+
expect(needsUpdate('<!-- MEMHUB:v0.1.0:START -->')).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('Agent Types', () => {
|
|
358
|
+
it('should have all expected agents', () => {
|
|
359
|
+
const agentIds = AGENTS.map(a => a.id);
|
|
360
|
+
expect(agentIds).toContain('cursor');
|
|
361
|
+
expect(agentIds).toContain('claude-code');
|
|
362
|
+
expect(agentIds).toContain('cline');
|
|
363
|
+
expect(agentIds).toContain('windsurf');
|
|
364
|
+
expect(agentIds).toContain('factory-droid');
|
|
365
|
+
expect(agentIds).toContain('gemini-cli');
|
|
366
|
+
expect(agentIds).toContain('codex');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should have valid config for each agent', () => {
|
|
370
|
+
AGENTS.forEach(agent => {
|
|
371
|
+
expect(agent.configFile).toBeTruthy();
|
|
372
|
+
expect(agent.globalConfigFile).toBeTruthy();
|
|
373
|
+
expect(agent.name).toBeTruthy();
|
|
374
|
+
expect(agent.configFormat).toMatch(/^(json|markdown|toml)$/);
|
|
375
|
+
expect(agent.instructionsFile).toBeTruthy();
|
|
376
|
+
expect(agent.globalInstructionsFile).toBeTruthy();
|
|
377
|
+
expect(agent.instructionsFormat).toMatch(/^(markdown|plain)$/);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
7
|
import { mkdtempSync, rmSync } from 'fs';
|
|
8
|
-
import { tmpdir } from 'os';
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import { createMcpServer } from '../../src/server/mcp-server.js';
|
|
8
|
+
import { tmpdir, homedir } from 'os';
|
|
9
|
+
import { join, resolve } from 'path';
|
|
10
|
+
import { createMcpServer, resolveStoragePath } from '../../src/server/mcp-server.js';
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
12
|
import {
|
|
13
13
|
TOOL_DEFINITIONS,
|
|
@@ -166,4 +166,46 @@ describe('McpServer (SDK)', () => {
|
|
|
166
166
|
expect(validInput.category).toBe('general');
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
describe('resolveStoragePath', () => {
|
|
171
|
+
const originalEnv = process.env.MEMHUB_STORAGE_PATH;
|
|
172
|
+
const originalCwd = process.cwd.bind(process);
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
if (originalEnv === undefined) {
|
|
176
|
+
delete process.env.MEMHUB_STORAGE_PATH;
|
|
177
|
+
} else {
|
|
178
|
+
process.env.MEMHUB_STORAGE_PATH = originalEnv;
|
|
179
|
+
}
|
|
180
|
+
process.cwd = originalCwd;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return ~/.memhub by default', () => {
|
|
184
|
+
delete process.env.MEMHUB_STORAGE_PATH;
|
|
185
|
+
const expectedPath = join(homedir(), '.memhub');
|
|
186
|
+
expect(resolveStoragePath()).toBe(expectedPath);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should use absolute path from MEMHUB_STORAGE_PATH', () => {
|
|
190
|
+
const absolutePath = process.platform === 'win32' ? 'C:\\custom\\path' : '/custom/path';
|
|
191
|
+
process.env.MEMHUB_STORAGE_PATH = absolutePath;
|
|
192
|
+
expect(resolveStoragePath()).toBe(absolutePath);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should resolve relative path from cwd', () => {
|
|
196
|
+
const cwd = process.platform === 'win32' ? 'C:\\project' : '/project';
|
|
197
|
+
process.cwd = () => cwd;
|
|
198
|
+
process.env.MEMHUB_STORAGE_PATH = '.memhub';
|
|
199
|
+
expect(resolveStoragePath()).toBe(resolve(cwd, '.memhub'));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should resolve .memhub relative path', () => {
|
|
203
|
+
const cwd = process.platform === 'win32' ? 'C:\\myproject' : '/myproject';
|
|
204
|
+
process.cwd = () => cwd;
|
|
205
|
+
process.env.MEMHUB_STORAGE_PATH = '.memhub';
|
|
206
|
+
const expected =
|
|
207
|
+
process.platform === 'win32' ? 'C:\\myproject\\.memhub' : '/myproject/.memhub';
|
|
208
|
+
expect(resolveStoragePath()).toBe(expected);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
169
211
|
});
|