@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.
- package/.eslintrc.cjs +46 -0
- package/.github/workflows/ci.yml +74 -0
- package/.iflow/commands/opsx-apply.md +152 -0
- package/.iflow/commands/opsx-archive.md +157 -0
- package/.iflow/commands/opsx-explore.md +173 -0
- package/.iflow/commands/opsx-propose.md +106 -0
- package/.iflow/skills/openspec-apply-change/SKILL.md +156 -0
- package/.iflow/skills/openspec-archive-change/SKILL.md +114 -0
- package/.iflow/skills/openspec-explore/SKILL.md +288 -0
- package/.iflow/skills/openspec-propose/SKILL.md +110 -0
- package/.prettierrc +11 -0
- package/README.md +171 -0
- package/README.zh-CN.md +169 -0
- package/dist/src/contracts/index.d.ts +7 -0
- package/dist/src/contracts/index.d.ts.map +1 -0
- package/dist/src/contracts/index.js +10 -0
- package/dist/src/contracts/index.js.map +1 -0
- package/dist/src/contracts/mcp.d.ts +194 -0
- package/dist/src/contracts/mcp.d.ts.map +1 -0
- package/dist/src/contracts/mcp.js +112 -0
- package/dist/src/contracts/mcp.js.map +1 -0
- package/dist/src/contracts/schemas.d.ts +1153 -0
- package/dist/src/contracts/schemas.d.ts.map +1 -0
- package/dist/src/contracts/schemas.js +246 -0
- package/dist/src/contracts/schemas.js.map +1 -0
- package/dist/src/contracts/types.d.ts +328 -0
- package/dist/src/contracts/types.d.ts.map +1 -0
- package/dist/src/contracts/types.js +30 -0
- package/dist/src/contracts/types.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/server/index.d.ts +5 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +5 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/mcp-server.d.ts +80 -0
- package/dist/src/server/mcp-server.d.ts.map +1 -0
- package/dist/src/server/mcp-server.js +263 -0
- package/dist/src/server/mcp-server.js.map +1 -0
- package/dist/src/services/index.d.ts +5 -0
- package/dist/src/services/index.d.ts.map +1 -0
- package/dist/src/services/index.js +5 -0
- package/dist/src/services/index.js.map +1 -0
- package/dist/src/services/memory-service.d.ts +105 -0
- package/dist/src/services/memory-service.d.ts.map +1 -0
- package/dist/src/services/memory-service.js +447 -0
- package/dist/src/services/memory-service.js.map +1 -0
- package/dist/src/storage/frontmatter-parser.d.ts +69 -0
- package/dist/src/storage/frontmatter-parser.d.ts.map +1 -0
- package/dist/src/storage/frontmatter-parser.js +207 -0
- package/dist/src/storage/frontmatter-parser.js.map +1 -0
- package/dist/src/storage/index.d.ts +6 -0
- package/dist/src/storage/index.d.ts.map +1 -0
- package/dist/src/storage/index.js +6 -0
- package/dist/src/storage/index.js.map +1 -0
- package/dist/src/storage/markdown-storage.d.ts +76 -0
- package/dist/src/storage/markdown-storage.d.ts.map +1 -0
- package/dist/src/storage/markdown-storage.js +193 -0
- package/dist/src/storage/markdown-storage.js.map +1 -0
- package/dist/src/utils/index.d.ts +5 -0
- package/dist/src/utils/index.d.ts.map +1 -0
- package/dist/src/utils/index.js +5 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/slugify.d.ts +24 -0
- package/dist/src/utils/slugify.d.ts.map +1 -0
- package/dist/src/utils/slugify.js +56 -0
- package/dist/src/utils/slugify.js.map +1 -0
- package/docs/architecture.md +349 -0
- package/docs/contracts.md +119 -0
- package/docs/prompt-template.md +79 -0
- package/docs/proposal-close-gates.md +58 -0
- package/docs/tool-calling-policy.md +107 -0
- package/package.json +53 -0
- package/src/contracts/index.ts +12 -0
- package/src/contracts/mcp.ts +303 -0
- package/src/contracts/schemas.ts +311 -0
- package/src/contracts/types.ts +414 -0
- package/src/index.ts +8 -0
- package/src/server/index.ts +5 -0
- package/src/server/mcp-server.ts +352 -0
- package/src/services/index.ts +5 -0
- package/src/services/memory-service.ts +548 -0
- package/src/storage/frontmatter-parser.ts +243 -0
- package/src/storage/index.ts +6 -0
- package/src/storage/markdown-storage.ts +236 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/slugify.ts +63 -0
- package/test/contracts/schemas.test.ts +313 -0
- package/test/contracts/types.test.ts +21 -0
- package/test/frontmatter-parser-more.test.ts +94 -0
- package/test/server/mcp-server-internals.test.ts +257 -0
- package/test/server/mcp-server.test.ts +97 -0
- package/test/services/memory-service-edge.test.ts +248 -0
- package/test/services/memory-service.test.ts +279 -0
- package/test/storage/frontmatter-parser.test.ts +223 -0
- package/test/storage/markdown-storage.test.ts +217 -0
- package/test/storage/storage-edge.test.ts +238 -0
- package/test/utils/slugify-edge.test.ts +94 -0
- package/test/utils/slugify.test.ts +68 -0
- package/tsconfig.json +26 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrontMatter Parser - Handles YAML Front Matter and Markdown content
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
6
|
+
import type { Memory, MemoryFrontMatter } from '../contracts/types.js';
|
|
7
|
+
import { slugify } from '../utils/slugify.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Result of parsing a markdown file
|
|
11
|
+
*/
|
|
12
|
+
export interface ParseResult {
|
|
13
|
+
frontMatter: MemoryFrontMatter;
|
|
14
|
+
title: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Custom error for front matter parsing
|
|
20
|
+
*/
|
|
21
|
+
export class FrontMatterError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
message: string,
|
|
24
|
+
public readonly cause?: unknown
|
|
25
|
+
) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'FrontMatterError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parses a markdown file with YAML front matter
|
|
33
|
+
*
|
|
34
|
+
* Expected format:
|
|
35
|
+
* ---
|
|
36
|
+
* id: "uuid"
|
|
37
|
+
* created_at: "ISO8601"
|
|
38
|
+
* updated_at: "ISO8601"
|
|
39
|
+
* tags: ["tag1", "tag2"]
|
|
40
|
+
* category: "category"
|
|
41
|
+
* importance: 3
|
|
42
|
+
* ---
|
|
43
|
+
*
|
|
44
|
+
* # Title
|
|
45
|
+
*
|
|
46
|
+
* Content...
|
|
47
|
+
*
|
|
48
|
+
* @param markdown - The markdown content to parse
|
|
49
|
+
* @returns Parsed front matter, title, and content
|
|
50
|
+
* @throws FrontMatterError if parsing fails
|
|
51
|
+
*/
|
|
52
|
+
export function parseFrontMatter(markdown: string): ParseResult {
|
|
53
|
+
// Check for front matter delimiter
|
|
54
|
+
if (!markdown.startsWith('---')) {
|
|
55
|
+
throw new FrontMatterError('Missing front matter delimiter');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Find the end of front matter
|
|
59
|
+
const endMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
60
|
+
if (!endMatch) {
|
|
61
|
+
throw new FrontMatterError('Invalid front matter format');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const frontMatterYaml = endMatch[1];
|
|
65
|
+
const restOfContent = markdown.slice(endMatch[0].length);
|
|
66
|
+
|
|
67
|
+
// Parse YAML front matter
|
|
68
|
+
let frontMatter: unknown;
|
|
69
|
+
try {
|
|
70
|
+
frontMatter = parseYaml(frontMatterYaml);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new FrontMatterError('Invalid YAML in front matter', error);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate required fields
|
|
76
|
+
if (!isValidFrontMatter(frontMatter)) {
|
|
77
|
+
throw new FrontMatterError('Missing required fields in front matter');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Parse title and content from markdown body
|
|
81
|
+
const { title, content } = parseMarkdownBody(restOfContent);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
frontMatter,
|
|
85
|
+
title,
|
|
86
|
+
content,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Converts front matter and content to markdown string
|
|
92
|
+
*
|
|
93
|
+
* @param frontMatter - The front matter data
|
|
94
|
+
* @param title - The title (H1 heading)
|
|
95
|
+
* @param content - The markdown content
|
|
96
|
+
* @returns Complete markdown string
|
|
97
|
+
*/
|
|
98
|
+
export function stringifyFrontMatter(
|
|
99
|
+
frontMatter: MemoryFrontMatter,
|
|
100
|
+
title: string,
|
|
101
|
+
content: string
|
|
102
|
+
): string {
|
|
103
|
+
// Convert camelCase to snake_case for YAML
|
|
104
|
+
const yamlData = {
|
|
105
|
+
id: frontMatter.id,
|
|
106
|
+
created_at: frontMatter.created_at,
|
|
107
|
+
updated_at: frontMatter.updated_at,
|
|
108
|
+
...(frontMatter.session_id ? { session_id: frontMatter.session_id } : {}),
|
|
109
|
+
...(frontMatter.entry_type ? { entry_type: frontMatter.entry_type } : {}),
|
|
110
|
+
tags: frontMatter.tags.length > 0 ? frontMatter.tags : [],
|
|
111
|
+
category: frontMatter.category,
|
|
112
|
+
importance: frontMatter.importance,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Stringify YAML with specific options for consistent formatting
|
|
116
|
+
const yamlString = stringifyYaml(yamlData, {
|
|
117
|
+
indent: 2,
|
|
118
|
+
defaultKeyType: 'PLAIN',
|
|
119
|
+
defaultStringType: 'QUOTE_DOUBLE',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Build the complete markdown
|
|
123
|
+
const parts: string[] = ['---', yamlString.trim(), '---', ''];
|
|
124
|
+
|
|
125
|
+
// Add title if provided
|
|
126
|
+
if (title) {
|
|
127
|
+
parts.push(`# ${title}`, '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Add content if provided
|
|
131
|
+
if (content) {
|
|
132
|
+
parts.push(content);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Ensure content ends with a single newline
|
|
136
|
+
let result = parts.join('\n');
|
|
137
|
+
if (!result.endsWith('\n')) {
|
|
138
|
+
result += '\n';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parses the markdown body to extract title and content
|
|
146
|
+
*
|
|
147
|
+
* @param body - The markdown body (after front matter)
|
|
148
|
+
* @returns Title and content
|
|
149
|
+
*/
|
|
150
|
+
function parseMarkdownBody(body: string): { title: string; content: string } {
|
|
151
|
+
const trimmed = body.trim();
|
|
152
|
+
|
|
153
|
+
if (!trimmed) {
|
|
154
|
+
return { title: '', content: '' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try to extract H1 title
|
|
158
|
+
const h1Match = trimmed.match(/^#\s+(.+)$/m);
|
|
159
|
+
if (h1Match) {
|
|
160
|
+
const title = h1Match[1].trim();
|
|
161
|
+
// Remove the H1 line from content
|
|
162
|
+
const content = trimmed.replace(/^#\s+.+$/m, '').trim();
|
|
163
|
+
return { title, content };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// No H1 found, treat entire body as content
|
|
167
|
+
return { title: '', content: trimmed };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Type guard to validate front matter structure
|
|
172
|
+
*
|
|
173
|
+
* @param value - The value to check
|
|
174
|
+
* @returns True if valid front matter
|
|
175
|
+
*/
|
|
176
|
+
function isValidFrontMatter(value: unknown): value is MemoryFrontMatter {
|
|
177
|
+
if (typeof value !== 'object' || value === null) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fm = value as Record<string, unknown>;
|
|
182
|
+
|
|
183
|
+
// Check required fields
|
|
184
|
+
if (typeof fm.id !== 'string') return false;
|
|
185
|
+
if (typeof fm.created_at !== 'string') return false;
|
|
186
|
+
if (typeof fm.updated_at !== 'string') return false;
|
|
187
|
+
if (fm.session_id !== undefined && typeof fm.session_id !== 'string') return false;
|
|
188
|
+
if (fm.entry_type !== undefined && typeof fm.entry_type !== 'string') return false;
|
|
189
|
+
if (!Array.isArray(fm.tags)) return false;
|
|
190
|
+
if (typeof fm.category !== 'string') return false;
|
|
191
|
+
if (typeof fm.importance !== 'number') return false;
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Converts a Memory object to front matter format
|
|
198
|
+
*
|
|
199
|
+
* @param memory - The memory object
|
|
200
|
+
* @returns Memory in front matter format
|
|
201
|
+
*/
|
|
202
|
+
export function memoryToFrontMatter(memory: Memory): MemoryFrontMatter {
|
|
203
|
+
return {
|
|
204
|
+
id: memory.id,
|
|
205
|
+
created_at: memory.createdAt,
|
|
206
|
+
updated_at: memory.updatedAt,
|
|
207
|
+
session_id: memory.sessionId,
|
|
208
|
+
entry_type: memory.entryType,
|
|
209
|
+
tags: memory.tags,
|
|
210
|
+
category: memory.category,
|
|
211
|
+
importance: memory.importance,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Converts front matter format to Memory object
|
|
217
|
+
*
|
|
218
|
+
* @param frontMatter - The front matter data
|
|
219
|
+
* @param title - The memory title
|
|
220
|
+
* @param content - The memory content
|
|
221
|
+
* @returns Complete Memory object
|
|
222
|
+
*/
|
|
223
|
+
export function frontMatterToMemory(
|
|
224
|
+
frontMatter: MemoryFrontMatter,
|
|
225
|
+
title: string,
|
|
226
|
+
content: string
|
|
227
|
+
): Memory {
|
|
228
|
+
return {
|
|
229
|
+
id: frontMatter.id,
|
|
230
|
+
createdAt: frontMatter.created_at,
|
|
231
|
+
updatedAt: frontMatter.updated_at,
|
|
232
|
+
sessionId: frontMatter.session_id,
|
|
233
|
+
entryType: frontMatter.entry_type,
|
|
234
|
+
tags: frontMatter.tags,
|
|
235
|
+
category: frontMatter.category,
|
|
236
|
+
importance: frontMatter.importance,
|
|
237
|
+
title,
|
|
238
|
+
content,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Re-export slugify for convenience
|
|
243
|
+
export { slugify };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown Storage - Handles file system operations for memory storage
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
readFile,
|
|
7
|
+
writeFile,
|
|
8
|
+
unlink,
|
|
9
|
+
readdir,
|
|
10
|
+
stat,
|
|
11
|
+
access,
|
|
12
|
+
mkdir,
|
|
13
|
+
} from 'fs/promises';
|
|
14
|
+
import { join, extname } from 'path';
|
|
15
|
+
import { constants } from 'fs';
|
|
16
|
+
import type { Memory, MemoryFile } from '../contracts/types.js';
|
|
17
|
+
import {
|
|
18
|
+
parseFrontMatter,
|
|
19
|
+
stringifyFrontMatter,
|
|
20
|
+
memoryToFrontMatter,
|
|
21
|
+
frontMatterToMemory,
|
|
22
|
+
FrontMatterError,
|
|
23
|
+
} from './frontmatter-parser.js';
|
|
24
|
+
import { slugify } from '../utils/slugify.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom error for storage operations
|
|
28
|
+
*/
|
|
29
|
+
export class StorageError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
public readonly cause?: unknown
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'StorageError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Storage configuration
|
|
41
|
+
*/
|
|
42
|
+
export interface StorageConfig {
|
|
43
|
+
/** Root directory for memory storage */
|
|
44
|
+
storagePath: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Markdown file storage implementation
|
|
49
|
+
*/
|
|
50
|
+
export class MarkdownStorage {
|
|
51
|
+
private readonly storagePath: string;
|
|
52
|
+
|
|
53
|
+
constructor(config: StorageConfig) {
|
|
54
|
+
this.storagePath = config.storagePath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ensures the storage directory exists
|
|
59
|
+
*/
|
|
60
|
+
async initialize(): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
await access(this.storagePath, constants.F_OK);
|
|
63
|
+
} catch {
|
|
64
|
+
// Directory doesn't exist, create it
|
|
65
|
+
await mkdir(this.storagePath, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Writes a memory to a markdown file
|
|
71
|
+
*
|
|
72
|
+
* @param memory - The memory to write
|
|
73
|
+
* @returns The file path where the memory was stored
|
|
74
|
+
* @throws StorageError if write fails
|
|
75
|
+
*/
|
|
76
|
+
async write(memory: Memory): Promise<string> {
|
|
77
|
+
await this.initialize();
|
|
78
|
+
|
|
79
|
+
const { directoryPath, filename } = this.generatePathParts(memory);
|
|
80
|
+
const filePath = join(directoryPath, filename);
|
|
81
|
+
const frontMatter = memoryToFrontMatter(memory);
|
|
82
|
+
const content = stringifyFrontMatter(frontMatter, memory.title, memory.content);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await mkdir(directoryPath, { recursive: true });
|
|
86
|
+
await writeFile(filePath, content, 'utf-8');
|
|
87
|
+
return filePath;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new StorageError(`Failed to write memory file: ${filename}`, error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads a memory by its ID
|
|
95
|
+
*
|
|
96
|
+
* @param id - The memory ID
|
|
97
|
+
* @returns The memory object
|
|
98
|
+
* @throws StorageError if memory not found or read fails
|
|
99
|
+
*/
|
|
100
|
+
async read(id: string): Promise<Memory> {
|
|
101
|
+
const filePath = await this.findById(id);
|
|
102
|
+
|
|
103
|
+
if (!filePath) {
|
|
104
|
+
throw new StorageError(`Memory not found: ${id}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const content = await readFile(filePath, 'utf-8');
|
|
109
|
+
const { frontMatter, title, content: bodyContent } = parseFrontMatter(content);
|
|
110
|
+
return frontMatterToMemory(frontMatter, title, bodyContent);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof FrontMatterError) {
|
|
113
|
+
throw new StorageError(`Invalid memory file format: ${filePath}`, error);
|
|
114
|
+
}
|
|
115
|
+
throw new StorageError(`Failed to read memory file: ${filePath}`, error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Deletes a memory by its ID
|
|
121
|
+
*
|
|
122
|
+
* @param id - The memory ID
|
|
123
|
+
* @returns The file path of the deleted memory
|
|
124
|
+
* @throws StorageError if memory not found or delete fails
|
|
125
|
+
*/
|
|
126
|
+
async delete(id: string): Promise<string> {
|
|
127
|
+
const filePath = await this.findById(id);
|
|
128
|
+
|
|
129
|
+
if (!filePath) {
|
|
130
|
+
throw new StorageError(`Memory not found: ${id}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await unlink(filePath);
|
|
135
|
+
return filePath;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new StorageError(`Failed to delete memory file: ${filePath}`, error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Lists all memory files
|
|
143
|
+
*
|
|
144
|
+
* @returns Array of memory file information
|
|
145
|
+
* @throws StorageError if listing fails
|
|
146
|
+
*/
|
|
147
|
+
async list(): Promise<MemoryFile[]> {
|
|
148
|
+
await this.initialize();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const markdownPaths = await this.collectMarkdownFiles(this.storagePath);
|
|
152
|
+
const files: MemoryFile[] = [];
|
|
153
|
+
|
|
154
|
+
for (const filePath of markdownPaths) {
|
|
155
|
+
const stats = await stat(filePath);
|
|
156
|
+
const content = await readFile(filePath, 'utf-8');
|
|
157
|
+
|
|
158
|
+
files.push({
|
|
159
|
+
path: filePath,
|
|
160
|
+
filename: filePath.split(/[/\\]/).pop() ?? filePath,
|
|
161
|
+
content,
|
|
162
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return files;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new StorageError('Failed to list memory files', error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Finds a memory file by ID
|
|
174
|
+
*
|
|
175
|
+
* @param id - The memory ID to find
|
|
176
|
+
* @returns The file path or null if not found
|
|
177
|
+
* @throws StorageError if search fails
|
|
178
|
+
*/
|
|
179
|
+
async findById(id: string): Promise<string | null> {
|
|
180
|
+
await this.initialize();
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const markdownPaths = await this.collectMarkdownFiles(this.storagePath);
|
|
184
|
+
|
|
185
|
+
for (const filePath of markdownPaths) {
|
|
186
|
+
try {
|
|
187
|
+
const content = await readFile(filePath, 'utf-8');
|
|
188
|
+
const { frontMatter } = parseFrontMatter(content);
|
|
189
|
+
if (frontMatter.id === id) {
|
|
190
|
+
return filePath;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Skip files that can't be parsed
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new StorageError('Failed to search for memory file', error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generates nested path parts for a memory
|
|
206
|
+
*
|
|
207
|
+
* Format: {storage}/{YYYY-MM-DD}/{session_uuid}/{timestamp}-{slug}.md
|
|
208
|
+
*/
|
|
209
|
+
private generatePathParts(memory: Memory): { directoryPath: string; filename: string } {
|
|
210
|
+
const date = memory.createdAt.split('T')[0];
|
|
211
|
+
const sessionId = memory.sessionId ?? 'default-session';
|
|
212
|
+
const titleSlug = slugify(memory.title) || 'untitled';
|
|
213
|
+
const timestamp = memory.createdAt.replace(/[:.]/g, '-');
|
|
214
|
+
const filename = `${timestamp}-${titleSlug}.md`;
|
|
215
|
+
const directoryPath = join(this.storagePath, date, sessionId);
|
|
216
|
+
|
|
217
|
+
return { directoryPath, filename };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async collectMarkdownFiles(rootDir: string): Promise<string[]> {
|
|
221
|
+
const entries = await readdir(rootDir, { withFileTypes: true });
|
|
222
|
+
const files: string[] = [];
|
|
223
|
+
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const entryPath = join(rootDir, entry.name);
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
const nested = await this.collectMarkdownFiles(entryPath);
|
|
228
|
+
files.push(...nested);
|
|
229
|
+
} else if (entry.isFile() && extname(entry.name) === '.md') {
|
|
230
|
+
files.push(entryPath);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return files;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +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
|
+
}
|