@synth-coder/memhub 0.2.1 → 0.2.3

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.
Files changed (71) hide show
  1. package/.eslintrc.cjs +45 -45
  2. package/.factory/commands/opsx-apply.md +150 -0
  3. package/.factory/commands/opsx-archive.md +155 -0
  4. package/.factory/commands/opsx-explore.md +171 -0
  5. package/.factory/commands/opsx-propose.md +104 -0
  6. package/.factory/skills/openspec-apply-change/SKILL.md +156 -0
  7. package/.factory/skills/openspec-archive-change/SKILL.md +114 -0
  8. package/.factory/skills/openspec-explore/SKILL.md +288 -0
  9. package/.factory/skills/openspec-propose/SKILL.md +110 -0
  10. package/.github/workflows/ci.yml +74 -74
  11. package/.iflow/commands/opsx-apply.md +152 -152
  12. package/.iflow/commands/opsx-archive.md +157 -157
  13. package/.iflow/commands/opsx-explore.md +173 -173
  14. package/.iflow/commands/opsx-propose.md +106 -106
  15. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -156
  16. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -114
  17. package/.iflow/skills/openspec-explore/SKILL.md +288 -288
  18. package/.iflow/skills/openspec-propose/SKILL.md +110 -110
  19. package/.prettierrc +11 -11
  20. package/AGENTS.md +169 -26
  21. package/README.md +195 -195
  22. package/README.zh-CN.md +193 -193
  23. package/dist/src/contracts/mcp.js +34 -34
  24. package/dist/src/server/mcp-server.d.ts +8 -0
  25. package/dist/src/server/mcp-server.d.ts.map +1 -1
  26. package/dist/src/server/mcp-server.js +23 -2
  27. package/dist/src/server/mcp-server.js.map +1 -1
  28. package/dist/src/services/memory-service.d.ts +1 -0
  29. package/dist/src/services/memory-service.d.ts.map +1 -1
  30. package/dist/src/services/memory-service.js +125 -82
  31. package/dist/src/services/memory-service.js.map +1 -1
  32. package/docs/architecture-diagrams.md +368 -0
  33. package/docs/architecture.md +381 -349
  34. package/docs/contracts.md +190 -119
  35. package/docs/prompt-template.md +33 -79
  36. package/docs/proposals/mcp-typescript-sdk-refactor.md +568 -568
  37. package/docs/proposals/proposal-close-gates.md +58 -58
  38. package/docs/tool-calling-policy.md +101 -107
  39. package/docs/vector-search.md +306 -0
  40. package/package.json +59 -58
  41. package/src/contracts/index.ts +12 -12
  42. package/src/contracts/mcp.ts +222 -222
  43. package/src/contracts/schemas.ts +307 -307
  44. package/src/contracts/types.ts +410 -410
  45. package/src/index.ts +8 -8
  46. package/src/server/index.ts +5 -5
  47. package/src/server/mcp-server.ts +185 -161
  48. package/src/services/embedding-service.ts +114 -114
  49. package/src/services/index.ts +5 -5
  50. package/src/services/memory-service.ts +663 -621
  51. package/src/storage/frontmatter-parser.ts +243 -243
  52. package/src/storage/index.ts +6 -6
  53. package/src/storage/markdown-storage.ts +236 -236
  54. package/src/storage/vector-index.ts +160 -160
  55. package/src/utils/index.ts +5 -5
  56. package/src/utils/slugify.ts +63 -63
  57. package/test/contracts/schemas.test.ts +313 -313
  58. package/test/contracts/types.test.ts +21 -21
  59. package/test/frontmatter-parser-more.test.ts +94 -94
  60. package/test/server/mcp-server.test.ts +210 -169
  61. package/test/services/memory-service-edge.test.ts +248 -248
  62. package/test/services/memory-service.test.ts +278 -278
  63. package/test/storage/frontmatter-parser.test.ts +222 -222
  64. package/test/storage/markdown-storage.test.ts +216 -216
  65. package/test/storage/storage-edge.test.ts +238 -238
  66. package/test/storage/vector-index.test.ts +153 -153
  67. package/test/utils/slugify-edge.test.ts +94 -94
  68. package/test/utils/slugify.test.ts +68 -68
  69. package/tsconfig.json +25 -25
  70. package/tsconfig.test.json +8 -8
  71. package/vitest.config.ts +29 -29
@@ -1,160 +1,160 @@
1
- /**
2
- * VectorIndex - LanceDB-backed vector search index for memories.
3
- *
4
- * This is a search cache only. Markdown files remain the source of truth.
5
- * The index can be rebuilt from Markdown files at any time.
6
- */
7
-
8
- import * as lancedb from '@lancedb/lancedb';
9
- import { mkdir, access } from 'fs/promises';
10
- import { join } from 'path';
11
- import { constants } from 'fs';
12
- import type { Memory } from '../contracts/types.js';
13
-
14
- const TABLE_NAME = 'memories';
15
-
16
- /** Escape single quotes in id strings to prevent SQL injection */
17
- function escapeId(id: string): string {
18
- return id.replace(/'/g, "''");
19
- }
20
-
21
- /**
22
- * Row stored in the LanceDB table.
23
- * The `vector` field is the only one required by LanceDB; all others are metadata filters.
24
- */
25
- export interface VectorRow {
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
- }
35
-
36
- export interface VectorSearchResult {
37
- id: string;
38
- /** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
39
- _distance: number;
40
- }
41
-
42
- /**
43
- * LanceDB vector index wrapper.
44
- * Data lives at `{storagePath}/.lancedb/`.
45
- */
46
- export class VectorIndex {
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
-
52
- constructor(storagePath: string) {
53
- this.dbPath = join(storagePath, '.lancedb');
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
- }
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 });
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
- }
123
-
124
- /**
125
- * Removes a memory from the index by ID.
126
- */
127
- async delete(id: string): Promise<void> {
128
- await this.initialize();
129
- await this.table!.delete(`id = '${escapeId(id)}'`);
130
- }
131
-
132
- /**
133
- * Searches for the nearest neighbours to `vector`.
134
- *
135
- * @param vector - Query embedding (must be 384-dim)
136
- * @param limit - Max results to return
137
- * @returns Array ordered by ascending distance (most similar first)
138
- */
139
- async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
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
- }));
151
- }
152
-
153
- /**
154
- * Returns the number of rows in the index.
155
- */
156
- async count(): Promise<number> {
157
- await this.initialize();
158
- return this.table!.countRows();
159
- }
160
- }
1
+ /**
2
+ * VectorIndex - LanceDB-backed vector search index for memories.
3
+ *
4
+ * This is a search cache only. Markdown files remain the source of truth.
5
+ * The index can be rebuilt from Markdown files at any time.
6
+ */
7
+
8
+ import * as lancedb from '@lancedb/lancedb';
9
+ import { mkdir, access } from 'fs/promises';
10
+ import { join } from 'path';
11
+ import { constants } from 'fs';
12
+ import type { Memory } from '../contracts/types.js';
13
+
14
+ const TABLE_NAME = 'memories';
15
+
16
+ /** Escape single quotes in id strings to prevent SQL injection */
17
+ function escapeId(id: string): string {
18
+ return id.replace(/'/g, "''");
19
+ }
20
+
21
+ /**
22
+ * Row stored in the LanceDB table.
23
+ * The `vector` field is the only one required by LanceDB; all others are metadata filters.
24
+ */
25
+ export interface VectorRow {
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
+ }
35
+
36
+ export interface VectorSearchResult {
37
+ id: string;
38
+ /** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
39
+ _distance: number;
40
+ }
41
+
42
+ /**
43
+ * LanceDB vector index wrapper.
44
+ * Data lives at `{storagePath}/.lancedb/`.
45
+ */
46
+ export class VectorIndex {
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
+
52
+ constructor(storagePath: string) {
53
+ this.dbPath = join(storagePath, '.lancedb');
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
+ }
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 });
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
+ }
123
+
124
+ /**
125
+ * Removes a memory from the index by ID.
126
+ */
127
+ async delete(id: string): Promise<void> {
128
+ await this.initialize();
129
+ await this.table!.delete(`id = '${escapeId(id)}'`);
130
+ }
131
+
132
+ /**
133
+ * Searches for the nearest neighbours to `vector`.
134
+ *
135
+ * @param vector - Query embedding (must be 384-dim)
136
+ * @param limit - Max results to return
137
+ * @returns Array ordered by ascending distance (most similar first)
138
+ */
139
+ async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
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
+ }));
151
+ }
152
+
153
+ /**
154
+ * Returns the number of rows in the index.
155
+ */
156
+ async count(): Promise<number> {
157
+ await this.initialize();
158
+ return this.table!.countRows();
159
+ }
160
+ }
@@ -1,5 +1,5 @@
1
- /**
2
- * Utility exports
3
- */
4
-
5
- export * from './slugify.js';
1
+ /**
2
+ * Utility exports
3
+ */
4
+
5
+ export * from './slugify.js';
@@ -1,63 +1,63 @@
1
- /**
2
- * Slugify utility - Converts strings to URL-friendly slugs
3
- */
4
-
5
- /**
6
- * Converts a string to a URL-friendly slug
7
- * - Converts to lowercase
8
- * - Replaces spaces with hyphens
9
- * - Removes special characters
10
- * - Collapses multiple hyphens
11
- * - Trims leading/trailing hyphens
12
- *
13
- * @param input - The string to convert
14
- * @returns The slugified string
15
- */
16
- export function slugify(input: string): string {
17
- if (!input || input.trim().length === 0) {
18
- return 'untitled';
19
- }
20
-
21
- // Convert to lowercase and replace non-alphanumeric characters with hyphens
22
- const slug = input
23
- .toLowerCase()
24
- .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
25
- .replace(/\s+/g, '-') // Replace spaces with hyphens
26
- .replace(/-+/g, '-') // Collapse multiple hyphens
27
- .replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
28
-
29
- // If empty after cleaning (e.g., only special characters), return 'untitled'
30
- if (slug.length === 0) {
31
- return 'untitled';
32
- }
33
-
34
- // Truncate to max 100 characters
35
- if (slug.length > 100) {
36
- return slug.substring(0, 100).replace(/-+$/, ''); // Don't end with hyphen
37
- }
38
-
39
- return slug;
40
- }
41
-
42
- /**
43
- * Generates a unique slug by appending a timestamp or counter if needed
44
- *
45
- * @param title - The title to slugify
46
- * @param existingSlugs - Array of existing slugs to check against
47
- * @returns A unique slug
48
- */
49
- export function generateUniqueSlug(title: string, existingSlugs: readonly string[] = []): string {
50
- const slug = slugify(title);
51
- let counter = 1;
52
- let uniqueSlug = slug;
53
-
54
- while (existingSlugs.includes(uniqueSlug)) {
55
- const suffix = `-${counter}`;
56
- const maxBaseLength = 100 - suffix.length;
57
- const baseSlug = slug.substring(0, maxBaseLength).replace(/-+$/, '');
58
- uniqueSlug = `${baseSlug}${suffix}`;
59
- counter++;
60
- }
61
-
62
- return uniqueSlug;
63
- }
1
+ /**
2
+ * Slugify utility - Converts strings to URL-friendly slugs
3
+ */
4
+
5
+ /**
6
+ * Converts a string to a URL-friendly slug
7
+ * - Converts to lowercase
8
+ * - Replaces spaces with hyphens
9
+ * - Removes special characters
10
+ * - Collapses multiple hyphens
11
+ * - Trims leading/trailing hyphens
12
+ *
13
+ * @param input - The string to convert
14
+ * @returns The slugified string
15
+ */
16
+ export function slugify(input: string): string {
17
+ if (!input || input.trim().length === 0) {
18
+ return 'untitled';
19
+ }
20
+
21
+ // Convert to lowercase and replace non-alphanumeric characters with hyphens
22
+ const slug = input
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
25
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
26
+ .replace(/-+/g, '-') // Collapse multiple hyphens
27
+ .replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
28
+
29
+ // If empty after cleaning (e.g., only special characters), return 'untitled'
30
+ if (slug.length === 0) {
31
+ return 'untitled';
32
+ }
33
+
34
+ // Truncate to max 100 characters
35
+ if (slug.length > 100) {
36
+ return slug.substring(0, 100).replace(/-+$/, ''); // Don't end with hyphen
37
+ }
38
+
39
+ return slug;
40
+ }
41
+
42
+ /**
43
+ * Generates a unique slug by appending a timestamp or counter if needed
44
+ *
45
+ * @param title - The title to slugify
46
+ * @param existingSlugs - Array of existing slugs to check against
47
+ * @returns A unique slug
48
+ */
49
+ export function generateUniqueSlug(title: string, existingSlugs: readonly string[] = []): string {
50
+ const slug = slugify(title);
51
+ let counter = 1;
52
+ let uniqueSlug = slug;
53
+
54
+ while (existingSlugs.includes(uniqueSlug)) {
55
+ const suffix = `-${counter}`;
56
+ const maxBaseLength = 100 - suffix.length;
57
+ const baseSlug = slug.substring(0, maxBaseLength).replace(/-+$/, '');
58
+ uniqueSlug = `${baseSlug}${suffix}`;
59
+ counter++;
60
+ }
61
+
62
+ return uniqueSlug;
63
+ }