@synth-coder/memhub 0.1.0

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 (104) hide show
  1. package/.eslintrc.cjs +46 -0
  2. package/.github/workflows/ci.yml +74 -0
  3. package/.iflow/commands/opsx-apply.md +152 -0
  4. package/.iflow/commands/opsx-archive.md +157 -0
  5. package/.iflow/commands/opsx-explore.md +173 -0
  6. package/.iflow/commands/opsx-propose.md +106 -0
  7. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -0
  8. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -0
  9. package/.iflow/skills/openspec-explore/SKILL.md +288 -0
  10. package/.iflow/skills/openspec-propose/SKILL.md +110 -0
  11. package/.prettierrc +11 -0
  12. package/README.md +171 -0
  13. package/README.zh-CN.md +169 -0
  14. package/dist/src/contracts/index.d.ts +7 -0
  15. package/dist/src/contracts/index.d.ts.map +1 -0
  16. package/dist/src/contracts/index.js +10 -0
  17. package/dist/src/contracts/index.js.map +1 -0
  18. package/dist/src/contracts/mcp.d.ts +194 -0
  19. package/dist/src/contracts/mcp.d.ts.map +1 -0
  20. package/dist/src/contracts/mcp.js +112 -0
  21. package/dist/src/contracts/mcp.js.map +1 -0
  22. package/dist/src/contracts/schemas.d.ts +1153 -0
  23. package/dist/src/contracts/schemas.d.ts.map +1 -0
  24. package/dist/src/contracts/schemas.js +246 -0
  25. package/dist/src/contracts/schemas.js.map +1 -0
  26. package/dist/src/contracts/types.d.ts +328 -0
  27. package/dist/src/contracts/types.d.ts.map +1 -0
  28. package/dist/src/contracts/types.js +30 -0
  29. package/dist/src/contracts/types.js.map +1 -0
  30. package/dist/src/index.d.ts +8 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +8 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/server/index.d.ts +5 -0
  35. package/dist/src/server/index.d.ts.map +1 -0
  36. package/dist/src/server/index.js +5 -0
  37. package/dist/src/server/index.js.map +1 -0
  38. package/dist/src/server/mcp-server.d.ts +80 -0
  39. package/dist/src/server/mcp-server.d.ts.map +1 -0
  40. package/dist/src/server/mcp-server.js +263 -0
  41. package/dist/src/server/mcp-server.js.map +1 -0
  42. package/dist/src/services/index.d.ts +5 -0
  43. package/dist/src/services/index.d.ts.map +1 -0
  44. package/dist/src/services/index.js +5 -0
  45. package/dist/src/services/index.js.map +1 -0
  46. package/dist/src/services/memory-service.d.ts +105 -0
  47. package/dist/src/services/memory-service.d.ts.map +1 -0
  48. package/dist/src/services/memory-service.js +447 -0
  49. package/dist/src/services/memory-service.js.map +1 -0
  50. package/dist/src/storage/frontmatter-parser.d.ts +69 -0
  51. package/dist/src/storage/frontmatter-parser.d.ts.map +1 -0
  52. package/dist/src/storage/frontmatter-parser.js +207 -0
  53. package/dist/src/storage/frontmatter-parser.js.map +1 -0
  54. package/dist/src/storage/index.d.ts +6 -0
  55. package/dist/src/storage/index.d.ts.map +1 -0
  56. package/dist/src/storage/index.js +6 -0
  57. package/dist/src/storage/index.js.map +1 -0
  58. package/dist/src/storage/markdown-storage.d.ts +76 -0
  59. package/dist/src/storage/markdown-storage.d.ts.map +1 -0
  60. package/dist/src/storage/markdown-storage.js +193 -0
  61. package/dist/src/storage/markdown-storage.js.map +1 -0
  62. package/dist/src/utils/index.d.ts +5 -0
  63. package/dist/src/utils/index.d.ts.map +1 -0
  64. package/dist/src/utils/index.js +5 -0
  65. package/dist/src/utils/index.js.map +1 -0
  66. package/dist/src/utils/slugify.d.ts +24 -0
  67. package/dist/src/utils/slugify.d.ts.map +1 -0
  68. package/dist/src/utils/slugify.js +56 -0
  69. package/dist/src/utils/slugify.js.map +1 -0
  70. package/docs/architecture.md +349 -0
  71. package/docs/contracts.md +119 -0
  72. package/docs/prompt-template.md +79 -0
  73. package/docs/proposal-close-gates.md +58 -0
  74. package/docs/tool-calling-policy.md +107 -0
  75. package/package.json +53 -0
  76. package/src/contracts/index.ts +12 -0
  77. package/src/contracts/mcp.ts +303 -0
  78. package/src/contracts/schemas.ts +311 -0
  79. package/src/contracts/types.ts +414 -0
  80. package/src/index.ts +8 -0
  81. package/src/server/index.ts +5 -0
  82. package/src/server/mcp-server.ts +352 -0
  83. package/src/services/index.ts +5 -0
  84. package/src/services/memory-service.ts +548 -0
  85. package/src/storage/frontmatter-parser.ts +243 -0
  86. package/src/storage/index.ts +6 -0
  87. package/src/storage/markdown-storage.ts +236 -0
  88. package/src/utils/index.ts +5 -0
  89. package/src/utils/slugify.ts +63 -0
  90. package/test/contracts/schemas.test.ts +313 -0
  91. package/test/contracts/types.test.ts +21 -0
  92. package/test/frontmatter-parser-more.test.ts +94 -0
  93. package/test/server/mcp-server-internals.test.ts +257 -0
  94. package/test/server/mcp-server.test.ts +97 -0
  95. package/test/services/memory-service-edge.test.ts +248 -0
  96. package/test/services/memory-service.test.ts +279 -0
  97. package/test/storage/frontmatter-parser.test.ts +223 -0
  98. package/test/storage/markdown-storage.test.ts +217 -0
  99. package/test/storage/storage-edge.test.ts +238 -0
  100. package/test/utils/slugify-edge.test.ts +94 -0
  101. package/test/utils/slugify.test.ts +68 -0
  102. package/tsconfig.json +26 -0
  103. package/tsconfig.test.json +8 -0
  104. package/vitest.config.ts +27 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * FrontMatter Parser Tests
3
+ * Tests for the FrontMatter parser
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import {
8
+ parseFrontMatter,
9
+ stringifyFrontMatter,
10
+ memoryToFrontMatter,
11
+ frontMatterToMemory,
12
+ FrontMatterError,
13
+ } from '../../src/storage/frontmatter-parser.js';
14
+ import type { Memory, MemoryFrontMatter } from '../../src/contracts/types.js';
15
+
16
+ describe('parseFrontMatter', () => {
17
+ it('should parse valid front matter and content', () => {
18
+ const markdown = `---
19
+ id: "550e8400-e29b-41d4-a716-446655440000"
20
+ created_at: "2024-03-15T10:30:00Z"
21
+ updated_at: "2024-03-15T14:20:00Z"
22
+ tags:
23
+ - project
24
+ - meeting
25
+ category: "work"
26
+ importance: 4
27
+ ---
28
+
29
+ # Project Meeting
30
+
31
+ This is the meeting content.
32
+
33
+ ## Action Items
34
+
35
+ - Item 1
36
+ - Item 2
37
+ `;
38
+
39
+ const result = parseFrontMatter(markdown);
40
+ expect(result.frontMatter.id).toBe('550e8400-e29b-41d4-a716-446655440000');
41
+ expect(result.frontMatter.category).toBe('work');
42
+ expect(result.frontMatter.importance).toBe(4);
43
+ expect(result.title).toBe('Project Meeting');
44
+ expect(result.content).toContain('Action Items');
45
+ });
46
+
47
+ it('should parse front matter with empty tags', () => {
48
+ const markdown = `---
49
+ id: "550e8400-e29b-41d4-a716-446655440000"
50
+ created_at: "2024-03-15T10:30:00Z"
51
+ updated_at: "2024-03-15T10:30:00Z"
52
+ tags: []
53
+ category: "general"
54
+ importance: 3
55
+ ---
56
+
57
+ # Empty Tags Test
58
+
59
+ Content here.
60
+ `;
61
+
62
+ const result = parseFrontMatter(markdown);
63
+ expect(result.frontMatter.tags).toEqual([]);
64
+ });
65
+
66
+ it('should throw error for missing front matter', () => {
67
+ const markdown = `# No Front Matter
68
+
69
+ This markdown has no front matter.
70
+ `;
71
+
72
+ expect(() => parseFrontMatter(markdown)).toThrow(FrontMatterError);
73
+ expect(() => parseFrontMatter(markdown)).toThrow('Missing front matter delimiter');
74
+ });
75
+
76
+ it('should throw error for invalid YAML', () => {
77
+ const markdown = `---
78
+ id: "550e8400-e29b-41d4-a716-446655440000"
79
+ created_at: [invalid yaml
80
+ ---
81
+
82
+ # Title
83
+
84
+ Content.
85
+ `;
86
+
87
+ expect(() => parseFrontMatter(markdown)).toThrow(FrontMatterError);
88
+ });
89
+
90
+ it('should throw error for missing required fields', () => {
91
+ const markdown = `---
92
+ id: "550e8400-e29b-41d4-a716-446655440000"
93
+ ---
94
+
95
+ # Title
96
+
97
+ Content.
98
+ `;
99
+
100
+ expect(() => parseFrontMatter(markdown)).toThrow(FrontMatterError);
101
+ expect(() => parseFrontMatter(markdown)).toThrow('Missing required fields');
102
+ });
103
+
104
+ it('should handle multiline content correctly', () => {
105
+ const markdown = `---
106
+ id: "550e8400-e29b-41d4-a716-446655440000"
107
+ created_at: "2024-03-15T10:30:00Z"
108
+ updated_at: "2024-03-15T10:30:00Z"
109
+ tags: []
110
+ category: "general"
111
+ importance: 3
112
+ ---
113
+
114
+ # Title
115
+
116
+ Line 1
117
+ Line 2
118
+
119
+ Line 3 after blank
120
+ `;
121
+
122
+ const result = parseFrontMatter(markdown);
123
+ expect(result.content).toContain('Line 1');
124
+ expect(result.content).toContain('Line 3 after blank');
125
+ });
126
+ });
127
+
128
+ describe('stringifyFrontMatter', () => {
129
+ const frontMatter: MemoryFrontMatter = {
130
+ id: '550e8400-e29b-41d4-a716-446655440000',
131
+ created_at: '2024-03-15T10:30:00Z',
132
+ updated_at: '2024-03-15T14:20:00Z',
133
+ tags: ['project', 'meeting'],
134
+ category: 'work',
135
+ importance: 4,
136
+ };
137
+
138
+ it('should stringify front matter and content', () => {
139
+ const result = stringifyFrontMatter(frontMatter, 'Title', 'Content here');
140
+ expect(result).toContain('---');
141
+ expect(result).toContain('id: "550e8400-e29b-41d4-a716-446655440000"');
142
+ expect(result).toContain('# Title');
143
+ expect(result).toContain('Content here');
144
+ });
145
+
146
+ it('should format tags as YAML array', () => {
147
+ const result = stringifyFrontMatter(frontMatter, 'Title', 'Content');
148
+ expect(result).toContain('tags:');
149
+ expect(result).toContain('project');
150
+ expect(result).toContain('meeting');
151
+ });
152
+
153
+ it('should use LF line endings', () => {
154
+ const result = stringifyFrontMatter(frontMatter, 'Title', 'Content');
155
+ expect(result).not.toContain('\r\n');
156
+ expect(result).toContain('\n');
157
+ });
158
+
159
+ it('should add blank line between front matter and content', () => {
160
+ const result = stringifyFrontMatter(frontMatter, 'Title', 'Content');
161
+ expect(result).toMatch(/---\n\n# Title/);
162
+ });
163
+
164
+ it('should handle empty tags', () => {
165
+ const fmWithEmptyTags: MemoryFrontMatter = { ...frontMatter, tags: [] };
166
+ const result = stringifyFrontMatter(fmWithEmptyTags, 'Title', 'Content');
167
+ expect(result).toContain('tags: []');
168
+ });
169
+
170
+ it('should handle multiline content', () => {
171
+ const content = 'Line 1\n\nLine 2\n\nLine 3';
172
+ const result = stringifyFrontMatter(frontMatter, 'Title', content);
173
+ expect(result).toContain('Line 1');
174
+ expect(result).toContain('Line 2');
175
+ expect(result).toContain('Line 3');
176
+ });
177
+ });
178
+
179
+ describe('memoryToFrontMatter', () => {
180
+ it('should convert Memory to MemoryFrontMatter', () => {
181
+ const memory: Memory = {
182
+ id: '550e8400-e29b-41d4-a716-446655440000',
183
+ createdAt: '2024-03-15T10:30:00Z',
184
+ updatedAt: '2024-03-15T14:20:00Z',
185
+ tags: ['test'],
186
+ category: 'work',
187
+ importance: 3,
188
+ title: 'Test',
189
+ content: 'Content',
190
+ };
191
+
192
+ const result = memoryToFrontMatter(memory);
193
+ expect(result.id).toBe(memory.id);
194
+ expect(result.created_at).toBe(memory.createdAt);
195
+ expect(result.updated_at).toBe(memory.updatedAt);
196
+ expect(result.tags).toEqual(memory.tags);
197
+ expect(result.category).toBe(memory.category);
198
+ expect(result.importance).toBe(memory.importance);
199
+ });
200
+ });
201
+
202
+ describe('frontMatterToMemory', () => {
203
+ it('should convert MemoryFrontMatter to Memory', () => {
204
+ const fm: MemoryFrontMatter = {
205
+ id: '550e8400-e29b-41d4-a716-446655440000',
206
+ created_at: '2024-03-15T10:30:00Z',
207
+ updated_at: '2024-03-15T14:20:00Z',
208
+ tags: ['test'],
209
+ category: 'work',
210
+ importance: 3,
211
+ };
212
+
213
+ const result = frontMatterToMemory(fm, 'Title', 'Content');
214
+ expect(result.id).toBe(fm.id);
215
+ expect(result.createdAt).toBe(fm.created_at);
216
+ expect(result.updatedAt).toBe(fm.updated_at);
217
+ expect(result.tags).toEqual(fm.tags);
218
+ expect(result.category).toBe(fm.category);
219
+ expect(result.importance).toBe(fm.importance);
220
+ expect(result.title).toBe('Title');
221
+ expect(result.content).toBe('Content');
222
+ });
223
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Markdown Storage Tests
3
+ * Tests for the MarkdownStorage class
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+ import { MarkdownStorage, StorageError } from '../../src/storage/markdown-storage.js';
11
+ import type { Memory } from '../../src/contracts/types.js';
12
+
13
+ describe('MarkdownStorage', () => {
14
+ let tempDir: string;
15
+ let storage: MarkdownStorage;
16
+
17
+ beforeEach(() => {
18
+ tempDir = mkdtempSync(join(tmpdir(), 'memhub-storage-test-'));
19
+ storage = new MarkdownStorage({ storagePath: tempDir });
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('write', () => {
27
+ const sampleMemory: Memory = {
28
+ id: '550e8400-e29b-41d4-a716-446655440000',
29
+ createdAt: '2024-03-15T10:30:00Z',
30
+ updatedAt: '2024-03-15T10:30:00Z',
31
+ sessionId: '550e8400-e29b-41d4-a716-446655440999',
32
+ tags: ['test', 'memory'],
33
+ category: 'testing',
34
+ importance: 3,
35
+ title: 'Test Memory',
36
+ content: 'This is the content of the test memory.',
37
+ };
38
+
39
+ it('should write memory to markdown file', async () => {
40
+ const result = await storage.write(sampleMemory);
41
+ expect(existsSync(result)).toBe(true);
42
+ expect(result).toContain(`${sampleMemory.createdAt.split('T')[0]}`);
43
+ expect(result).toContain(sampleMemory.sessionId as string);
44
+ });
45
+
46
+ it('should write valid YAML front matter', async () => {
47
+ const filePath = await storage.write(sampleMemory);
48
+ const content = readFileSync(filePath, 'utf-8');
49
+ expect(content).toMatch(/^---\n/);
50
+ expect(content).toContain('id: "550e8400-e29b-41d4-a716-446655440000"');
51
+ expect(content).toContain('created_at: "2024-03-15T10:30:00Z"');
52
+ expect(content).toContain('session_id: "550e8400-e29b-41d4-a716-446655440999"');
53
+ });
54
+
55
+ it('should write markdown body with title', async () => {
56
+ const filePath = await storage.write(sampleMemory);
57
+ const content = readFileSync(filePath, 'utf-8');
58
+ expect(content).toContain('# Test Memory');
59
+ expect(content).toContain('This is the content');
60
+ });
61
+
62
+ it('should write tags as YAML array', async () => {
63
+ const filePath = await storage.write(sampleMemory);
64
+ const content = readFileSync(filePath, 'utf-8');
65
+ expect(content).toContain('tags:');
66
+ expect(content).toContain('test');
67
+ expect(content).toContain('memory');
68
+ });
69
+
70
+ it('should return nested file path', async () => {
71
+ const result = await storage.write(sampleMemory);
72
+ expect(result).toContain('test-memory.md');
73
+ expect(result).toContain('2024-03-15');
74
+ expect(result).toContain('550e8400-e29b-41d4-a716-446655440999');
75
+ });
76
+ });
77
+
78
+ describe('read', () => {
79
+ it('should read memory from markdown file', async () => {
80
+ // Create a test file first
81
+ const testContent = `---
82
+ id: "550e8400-e29b-41d4-a716-446655440000"
83
+ created_at: "2024-03-15T10:30:00Z"
84
+ updated_at: "2024-03-15T10:30:00Z"
85
+ tags:
86
+ - test
87
+ category: "testing"
88
+ importance: 3
89
+ ---
90
+
91
+ # Test Memory
92
+
93
+ This is the content.
94
+ `;
95
+ const filePath = join(tempDir, 'test.md');
96
+ // Use sync write for test setup
97
+ const { writeFileSync } = await import('fs');
98
+ writeFileSync(filePath, testContent);
99
+
100
+ const memory = await storage.read('550e8400-e29b-41d4-a716-446655440000');
101
+ expect(memory.id).toBe('550e8400-e29b-41d4-a716-446655440000');
102
+ expect(memory.title).toBe('Test Memory');
103
+ });
104
+
105
+ it('should throw error when file not found', async () => {
106
+ await expect(
107
+ storage.read('550e8400-e29b-41d4-a716-446655440000')
108
+ ).rejects.toThrow(StorageError);
109
+ });
110
+
111
+ it('should parse front matter correctly', async () => {
112
+ const testContent = `---
113
+ id: "550e8400-e29b-41d4-a716-446655440000"
114
+ created_at: "2024-03-15T10:30:00Z"
115
+ updated_at: "2024-03-15T14:20:00Z"
116
+ tags:
117
+ - tag1
118
+ - tag2
119
+ category: "work"
120
+ importance: 4
121
+ ---
122
+
123
+ # Test Title
124
+
125
+ Test content.
126
+ `;
127
+ const { writeFileSync } = await import('fs');
128
+ writeFileSync(join(tempDir, 'test.md'), testContent);
129
+
130
+ const memory = await storage.read('550e8400-e29b-41d4-a716-446655440000');
131
+ expect(memory.tags).toEqual(['tag1', 'tag2']);
132
+ expect(memory.category).toBe('work');
133
+ expect(memory.importance).toBe(4);
134
+ });
135
+ });
136
+
137
+ describe('delete', () => {
138
+ it('should delete memory file', async () => {
139
+ // Create a test file first
140
+ const testContent = `---
141
+ id: "550e8400-e29b-41d4-a716-446655440000"
142
+ created_at: "2024-03-15T10:30:00Z"
143
+ updated_at: "2024-03-15T10:30:00Z"
144
+ tags: []
145
+ category: "general"
146
+ importance: 3
147
+ ---
148
+
149
+ # Test
150
+ `;
151
+ const filePath = join(tempDir, 'test.md');
152
+ const { writeFileSync } = await import('fs');
153
+ writeFileSync(filePath, testContent);
154
+
155
+ await storage.delete('550e8400-e29b-41d4-a716-446655440000');
156
+ expect(existsSync(filePath)).toBe(false);
157
+ });
158
+
159
+ it('should throw error when file not found', async () => {
160
+ await expect(
161
+ storage.delete('550e8400-e29b-41d4-a716-446655440000')
162
+ ).rejects.toThrow(StorageError);
163
+ });
164
+ });
165
+
166
+ describe('list', () => {
167
+ it('should list all memory files', async () => {
168
+ const { writeFileSync } = await import('fs');
169
+ writeFileSync(join(tempDir, '2024-03-15-a.md'), '---\nid: "a"\ncreated_at: "2024-03-15T10:30:00Z"\nupdated_at: "2024-03-15T10:30:00Z"\ntags: []\ncategory: "general"\nimportance: 3\n---\n\n# A');
170
+ writeFileSync(join(tempDir, '2024-03-16-b.md'), '---\nid: "b"\ncreated_at: "2024-03-16T10:30:00Z"\nupdated_at: "2024-03-16T10:30:00Z"\ntags: []\ncategory: "general"\nimportance: 3\n---\n\n# B');
171
+
172
+ const files = await storage.list();
173
+ expect(files).toHaveLength(2);
174
+ });
175
+
176
+ it('should return empty array when no files exist', async () => {
177
+ const files = await storage.list();
178
+ expect(files).toEqual([]);
179
+ });
180
+
181
+ it('should only include .md files', async () => {
182
+ const { writeFileSync } = await import('fs');
183
+ writeFileSync(join(tempDir, 'test.md'), '---\nid: "test"\ncreated_at: "2024-03-15T10:30:00Z"\nupdated_at: "2024-03-15T10:30:00Z"\ntags: []\ncategory: "general"\nimportance: 3\n---\n\n# Test');
184
+ writeFileSync(join(tempDir, 'test.txt'), 'not markdown');
185
+
186
+ const files = await storage.list();
187
+ expect(files).toHaveLength(1);
188
+ expect(files[0].filename).toBe('test.md');
189
+ });
190
+ });
191
+
192
+ describe('findById', () => {
193
+ it('should find file by memory ID', async () => {
194
+ const testContent = `---
195
+ id: "550e8400-e29b-41d4-a716-446655440000"
196
+ created_at: "2024-03-15T10:30:00Z"
197
+ updated_at: "2024-03-15T10:30:00Z"
198
+ tags: []
199
+ category: "general"
200
+ importance: 3
201
+ ---
202
+
203
+ # Test
204
+ `;
205
+ const { writeFileSync } = await import('fs');
206
+ writeFileSync(join(tempDir, 'test.md'), testContent);
207
+
208
+ const filePath = await storage.findById('550e8400-e29b-41d4-a716-446655440000');
209
+ expect(filePath).toContain('test.md');
210
+ });
211
+
212
+ it('should return null when ID not found', async () => {
213
+ const filePath = await storage.findById('550e8400-e29b-41d4-a716-446655440000');
214
+ expect(filePath).toBeNull();
215
+ });
216
+ });
217
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Storage Edge Case Tests
3
+ * Additional tests for better coverage
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+ import { MarkdownStorage, StorageError } from '../../src/storage/markdown-storage.js';
11
+ import { FrontMatterError } from '../../src/storage/frontmatter-parser.js';
12
+
13
+ describe('MarkdownStorage Edge Cases', () => {
14
+ let tempDir: string;
15
+ let storage: MarkdownStorage;
16
+
17
+ beforeEach(() => {
18
+ tempDir = mkdtempSync(join(tmpdir(), 'memhub-storage-edge-'));
19
+ storage = new MarkdownStorage({ storagePath: tempDir });
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('initialize', () => {
27
+ it('should create directory if it does not exist', async () => {
28
+ const newDir = join(tempDir, 'subdir', 'new-storage');
29
+ const newStorage = new MarkdownStorage({ storagePath: newDir });
30
+ await newStorage.initialize();
31
+ // Directory should be created
32
+ const { existsSync } = await import('fs');
33
+ expect(existsSync(newDir)).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe('write edge cases', () => {
38
+ it('should handle memory with empty title', async () => {
39
+ const memory = {
40
+ id: '550e8400-e29b-41d4-a716-446655440000',
41
+ createdAt: '2024-03-15T10:30:00Z',
42
+ updatedAt: '2024-03-15T10:30:00Z',
43
+ tags: [],
44
+ category: 'test',
45
+ importance: 3,
46
+ title: '',
47
+ content: 'Content with empty title',
48
+ };
49
+
50
+ const filePath = await storage.write(memory);
51
+ expect(filePath).toContain('untitled');
52
+ });
53
+
54
+ it('should handle memory with special characters in title', async () => {
55
+ const memory = {
56
+ id: '550e8400-e29b-41d4-a716-446655440001',
57
+ createdAt: '2024-03-15T10:30:00Z',
58
+ updatedAt: '2024-03-15T10:30:00Z',
59
+ tags: [],
60
+ category: 'test',
61
+ importance: 3,
62
+ title: 'Title with @#$%^&*()',
63
+ content: 'Content',
64
+ };
65
+
66
+ const filePath = await storage.write(memory);
67
+ expect(filePath).toBeDefined();
68
+ });
69
+
70
+ it('should handle very long content', async () => {
71
+ const memory = {
72
+ id: '550e8400-e29b-41d4-a716-446655440002',
73
+ createdAt: '2024-03-15T10:30:00Z',
74
+ updatedAt: '2024-03-15T10:30:00Z',
75
+ tags: [],
76
+ category: 'test',
77
+ importance: 3,
78
+ title: 'Long Content',
79
+ content: 'A'.repeat(10000),
80
+ };
81
+
82
+ const filePath = await storage.write(memory);
83
+ const { readFileSync } = await import('fs');
84
+ const content = readFileSync(filePath, 'utf-8');
85
+ expect(content).toContain('A'.repeat(100));
86
+ });
87
+ });
88
+
89
+ describe('read edge cases', () => {
90
+ it('should throw error for invalid front matter', async () => {
91
+ const invalidContent = '---\ninvalid yaml: [\n---\n\n# Title';
92
+ writeFileSync(join(tempDir, 'invalid.md'), invalidContent);
93
+
94
+ await expect(storage.read('any-id')).rejects.toThrow(StorageError);
95
+ });
96
+
97
+ it('should throw error for missing required fields', async () => {
98
+ const incompleteContent = '---\nid: "test-id"\n---\n\n# Title';
99
+ writeFileSync(join(tempDir, 'incomplete.md'), incompleteContent);
100
+
101
+ await expect(storage.read('test-id')).rejects.toThrow(StorageError);
102
+ });
103
+
104
+ it('should handle file with no H1 title', async () => {
105
+ const noTitleContent = `---
106
+ id: "550e8400-e29b-41d4-a716-446655440000"
107
+ created_at: "2024-03-15T10:30:00Z"
108
+ updated_at: "2024-03-15T10:30:00Z"
109
+ tags: []
110
+ category: "general"
111
+ importance: 3
112
+ ---
113
+
114
+ Just some content without H1.
115
+ `;
116
+ writeFileSync(join(tempDir, 'no-title.md'), noTitleContent);
117
+
118
+ const memory = await storage.read('550e8400-e29b-41d4-a716-446655440000');
119
+ expect(memory.title).toBe('');
120
+ expect(memory.content).toContain('Just some content');
121
+ });
122
+ });
123
+
124
+ describe('list edge cases', () => {
125
+ it('should skip invalid markdown files', async () => {
126
+ // Create valid file
127
+ const validContent = `---
128
+ id: "valid-id"
129
+ created_at: "2024-03-15T10:30:00Z"
130
+ updated_at: "2024-03-15T10:30:00Z"
131
+ tags: []
132
+ category: "general"
133
+ importance: 3
134
+ ---
135
+
136
+ # Valid
137
+ `;
138
+ writeFileSync(join(tempDir, 'valid.md'), validContent);
139
+
140
+ // Create invalid file
141
+ writeFileSync(join(tempDir, 'invalid.md'), 'Not valid markdown');
142
+
143
+ const files = await storage.list();
144
+ expect(files).toHaveLength(2);
145
+ });
146
+
147
+ it('should handle empty directory', async () => {
148
+ const files = await storage.list();
149
+ expect(files).toEqual([]);
150
+ });
151
+
152
+ it('should ignore non-markdown files', async () => {
153
+ writeFileSync(join(tempDir, 'test.txt'), 'Text file');
154
+ writeFileSync(join(tempDir, 'test.json'), '{}');
155
+
156
+ const files = await storage.list();
157
+ expect(files).toHaveLength(0);
158
+ });
159
+ });
160
+
161
+ describe('findById edge cases', () => {
162
+ it('should return null when no files exist', async () => {
163
+ const result = await storage.findById('any-id');
164
+ expect(result).toBeNull();
165
+ });
166
+
167
+ it('should skip files with invalid front matter', async () => {
168
+ writeFileSync(join(tempDir, 'invalid.md'), 'Invalid content');
169
+
170
+ const result = await storage.findById('any-id');
171
+ expect(result).toBeNull();
172
+ });
173
+
174
+ it('should find correct file among multiple files', async () => {
175
+ const content1 = `---
176
+ id: "id-1"
177
+ created_at: "2024-03-15T10:30:00Z"
178
+ updated_at: "2024-03-15T10:30:00Z"
179
+ tags: []
180
+ category: "general"
181
+ importance: 3
182
+ ---
183
+
184
+ # File 1
185
+ `;
186
+ const content2 = `---
187
+ id: "id-2"
188
+ created_at: "2024-03-15T10:30:00Z"
189
+ updated_at: "2024-03-15T10:30:00Z"
190
+ tags: []
191
+ category: "general"
192
+ importance: 3
193
+ ---
194
+
195
+ # File 2
196
+ `;
197
+ writeFileSync(join(tempDir, 'file1.md'), content1);
198
+ writeFileSync(join(tempDir, 'file2.md'), content2);
199
+
200
+ const result = await storage.findById('id-2');
201
+ expect(result).toContain('file2.md');
202
+ });
203
+ });
204
+
205
+ describe('delete edge cases', () => {
206
+ it('should throw error when trying to delete non-existent memory', async () => {
207
+ await expect(storage.delete('non-existent-id')).rejects.toThrow(StorageError);
208
+ });
209
+ });
210
+ });
211
+
212
+ describe('FrontMatterError', () => {
213
+ it('should create error with message', () => {
214
+ const error = new FrontMatterError('Test error');
215
+ expect(error.message).toBe('Test error');
216
+ expect(error.name).toBe('FrontMatterError');
217
+ });
218
+
219
+ it('should create error with cause', () => {
220
+ const cause = new Error('Original error');
221
+ const error = new FrontMatterError('Test error', cause);
222
+ expect(error.cause).toBe(cause);
223
+ });
224
+ });
225
+
226
+ describe('StorageError', () => {
227
+ it('should create error with message', () => {
228
+ const error = new StorageError('Test error');
229
+ expect(error.message).toBe('Test error');
230
+ expect(error.name).toBe('StorageError');
231
+ });
232
+
233
+ it('should create error with cause', () => {
234
+ const cause = new Error('Original error');
235
+ const error = new StorageError('Test error', cause);
236
+ expect(error.cause).toBe(cause);
237
+ });
238
+ });